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
data/README.md CHANGED
@@ -1,31 +1,22 @@
1
1
  # TypedEAV
2
2
 
3
- Add dynamic custom fields to ActiveRecord models at runtime, backed by **native database typed columns** instead of jsonb blobs.
3
+ Add runtime-defined custom fields to Active Record models, with typed values,
4
+ validation, and SQL filtering, sorting, and summaries.
4
5
 
5
- TypedEAV uses a hybrid EAV (Entity-Attribute-Value) pattern where each value type gets its own column (`integer_value`, `date_value`, `string_value`, etc.) in the values table. This lets PostgreSQL index, sort, and enforce constraints on custom field data while the Field owns input normalization and validation.
6
+ Scalar values use native PostgreSQL columns such as `integer_value` and
7
+ `date_value`; collection and JSON fields use JSONB. Field definitions control
8
+ casting and validation, and can be shared globally or scoped to tenants.
6
9
 
7
- ## Why Typed Columns?
8
-
9
- JSONB is a useful fit when an application owns stable paths and wants expression B-tree indexes or GIN containment indexes. A single JSONB document is not inherently faster or slower than TypedEAV; the right choice depends on access patterns, selectivity, update shape, and operational constraints. For example, an application-owned expression index may support a stable path:
10
-
11
- ```sql
12
- CAST(value_meta->>'const' AS bigint) = 42
13
- ```
14
-
15
- This can work well for stable, known paths. It does not provide the same column-level schema and typed-value contract as TypedEAV, and arbitrary paths still require application-owned index and validation decisions. GIN is useful for containment workloads; expression B-trees are useful for selected stable scalar paths.
16
-
17
- TypedEAV stores values in native columns, so queries become:
18
-
19
- ```sql
20
- WHERE integer_value = 42
21
- ```
22
-
23
- TypedEAV supplies stable typed columns and ordinary per-type indexes. Range scans and sorts can use those indexes, while each Field casts and validates the operand according to its own semantics before the query reaches the typed column. Neither design is a universal storage winner; choose from measured workload fit.
10
+ TypedEAV is useful when users define fields at runtime and your application
11
+ needs to query them through a consistent typed interface. JSONB also works well
12
+ for many workloads; choose based on your query patterns and operational needs.
13
+ See [Storage and performance](docs/guides/performance.md) for the tradeoffs,
14
+ indexing guidance, and benchmark evidence.
24
15
 
25
16
  ## Compatibility
26
17
 
27
18
  The canonical support contract lives in
28
- [`.github/compatibility.json`](.github/compatibility.json). Typed EAV supports:
19
+ [`.github/compatibility.json`](https://github.com/dchuk/typed_eav/blob/main/.github/compatibility.json). Typed EAV supports:
29
20
 
30
21
  | Runtime | Supported versions |
31
22
  |---|---|
@@ -53,624 +44,46 @@ bin/rails typed_eav:install:migrations
53
44
  bin/rails db:migrate
54
45
  ```
55
46
 
47
+ PostgreSQL is required; MySQL and SQLite are not supported. For existing
48
+ installations, see [Upgrading](docs/guides/upgrading.md) and the
49
+ [Changelog](CHANGELOG.md).
50
+
56
51
  ## Quick Start
57
52
 
58
- ### 1. Include the concern
53
+ Assuming your application already has a `Contact` model and table:
59
54
 
60
55
  ```ruby
61
56
  class Contact < ApplicationRecord
62
57
  has_typed_eav
63
58
  end
64
59
 
65
- # With multi-tenant scoping:
66
- class Contact < ApplicationRecord
67
- has_typed_eav scope_method: :tenant_id
68
- end
69
-
70
- # With restricted field types:
71
- class Contact < ApplicationRecord
72
- has_typed_eav types: [:text, :integer, :boolean, :select]
73
- end
74
- ```
75
-
76
- ### 2. Create field definitions
77
-
78
- ```ruby
79
- # Simple fields
80
- TypedEAV::Field::Text.create!(
81
- name: "nickname",
82
- entity_type: "Contact"
83
- )
84
-
85
60
  TypedEAV::Field::Integer.create!(
86
61
  name: "age",
87
- entity_type: "Contact",
88
- required: true,
62
+ entity_type: Contact.polymorphic_name,
89
63
  options: { min: 0, max: 150 }
90
64
  )
91
65
 
92
- TypedEAV::Field::Date.create!(
93
- name: "birthday",
94
- entity_type: "Contact",
95
- options: { max_date: Date.today.to_s }
96
- )
97
-
98
- # Select field with options
99
- status = TypedEAV::Field::Select.create!(
100
- name: "status",
101
- entity_type: "Contact",
102
- required: true
103
- )
104
- status.field_options.create!([
105
- { label: "Active", value: "active", sort_order: 1 },
106
- { label: "Inactive", value: "inactive", sort_order: 2 },
107
- { label: "Lead", value: "lead", sort_order: 3 },
108
- ])
109
-
110
- # Multi-select (stored as json array)
111
- tags = TypedEAV::Field::MultiSelect.create!(
112
- name: "tags",
113
- entity_type: "Contact"
114
- )
115
- tags.field_options.create!([
116
- { label: "VIP", value: "vip" },
117
- { label: "Partner", value: "partner" },
118
- { label: "Prospect", value: "prospect" },
119
- ])
120
- ```
121
-
122
- When deriving `entity_type` from a model class, use
123
- `Contact.polymorphic_name`. Rails stores polymorphic associations under that
124
- canonical name, which is the base-class type for STI hosts and respects the
125
- application's namespaced-polymorphism setting.
126
-
127
- ### 3. Set values on records
128
-
129
- ```ruby
130
- contact = Contact.new(name: "Darrin")
131
-
132
- # Individual assignment
133
- contact.set_typed_eav_value("age", 40)
134
- contact.set_typed_eav_value("status", "active")
135
-
136
- # Bulk assignment by field NAME (ergonomic for scripting / seeds)
137
- contact.typed_eav_attributes = [
138
- { name: "age", value: 40 },
139
- { name: "status", value: "active" },
140
- { name: "tags", value: ["vip", "partner"] },
141
- ]
66
+ contact = Contact.new
67
+ contact.set_typed_eav_value("age", "40")
68
+ contact.save! # Supply any other attributes your Contact model requires.
142
69
 
143
- # Bulk assignment by field ID (standard Rails form contract).
144
- # Your form templates emit this shape when you use fields_for :typed_values.
145
- contact.typed_values_attributes = [
146
- { id: 12, field_id: 4, value: "40" },
147
- { field_id: 7, value: "active" },
148
- ]
70
+ contact.typed_eav_value("age") # => 40 (Integer)
71
+ contact.typed_eav_hash # => { "age" => 40 }
149
72
 
150
- contact.save!
151
-
152
- # Reading
153
- contact.typed_eav_value("age") # => 40 (Ruby Integer)
154
- contact.typed_eav_value("status") # => "active"
155
- contact.typed_eav_hash # => { "age" => 40, "status" => "active", ... }
156
- ```
157
-
158
- ### 4. Query with the DSL
159
-
160
- Queries use native typed columns and the indexes shipped for each type. The Field remains the owner of operand casting and validation; the query builder receives a field-normalized value rather than applying a generic Active Record cast.
161
-
162
- ```ruby
163
- # Short form - single field filter
164
- Contact.with_field("age", :gt, 21)
165
- Contact.with_field("status", "active") # :eq is the default operator
166
- Contact.with_field("nickname", :contains, "smith")
167
-
168
- # Chain them
169
- Contact.with_field("age", :gteq, 18)
170
- .with_field("status", "active")
171
- .with_field("tags", :any_eq, "vip")
172
-
173
- # Multi-filter form (good for search UIs)
174
- Contact.where_typed_eav(
175
- { name: "age", op: :gt, value: 21 },
176
- { name: "status", op: :eq, value: "active" },
177
- { name: "city", op: :contains, value: "port" },
178
- )
179
-
180
- # Compact keys (for URL params / form submissions)
181
- Contact.where_typed_eav(
182
- { n: "age", op: :gt, v: 21 },
183
- { n: "status", v: "active" },
184
- )
185
-
186
- # With scoping
187
- Contact.where_typed_eav(
188
- { name: "priority", op: :eq, value: "high" },
189
- scope: current_tenant.id
190
- )
191
-
192
- # Combine with standard ActiveRecord
193
- Contact.where(company_id: 42)
194
- .with_field("status", "active")
195
- .with_field("age", :gteq, 21)
196
- .order(:name)
73
+ Contact.with_field("age", :gteq, 21)
74
+ .order_typed_eav("age", direction: :desc)
197
75
  .limit(25)
198
76
  ```
199
77
 
200
- ### Sorting by a typed field
201
-
202
- ```ruby
203
- Contact.where(tenant_id: "t1")
204
- .order_typed_eav("age", direction: :desc, nulls: :last, scope: "t1")
205
- .limit(25)
206
- ```
207
-
208
- `order_typed_eav` returns an Active Record relation and orders in PostgreSQL,
209
- without loading typed values into Ruby. It replaces prior ordering while
210
- preserving host filters, STI restrictions, limits, and offsets. `direction:`
211
- accepts `:asc` (default) or `:desc`; `nulls:` accepts `:first` or `:last`
212
- (default in either direction). Missing rows and explicit NULLs share that
213
- placement. Equal values use the host primary key ascending as a stable tie-break.
214
-
215
- Scope arguments (or the ambient scope) select the winning field definition;
216
- **they do not filter host records by tenant**. Keep authorization/tenant filters
217
- on the caller relation. All-partitions `TypedEAV.unscoped` is rejected for this
218
- API: choose one effective definition instead. Single native scalar cells are
219
- supported, using their stored values (for example, reference IDs and attachment
220
- signed IDs, not display labels). JSON/array and multi-cell fields such as
221
- Currency are rejected rather than assigned an implicit ordering.
222
-
223
- ### Distinct values and grouped counts
224
-
225
- ```ruby
226
- contacts = Contact.where(tenant_id: "t1")
227
- contacts.distinct_typed_eav_values("status", scope: "t1", limit: 100)
228
- # => ["active", "paused", nil]
229
- contacts.typed_eav_value_counts("status", scope: "t1", limit: 100)
230
- # => {"active" => 24, "paused" => 3, nil => 2}
231
- contacts.count_distinct_typed_eav_values("status", scope: "t1")
232
- # => 3
233
- ```
234
-
235
- These scalar queries run in SQL without hydrating hosts or Values. Results
236
- use native ascending value order, with explicit NULL (`nil`) last. Missing
237
- value rows contribute nothing; `false` and empty strings remain real values.
238
- Grouped counts count host identities, not duplicate rows introduced by joins.
239
- The caller's filters, STI restrictions, distinctness, and pagination determine
240
- the host set before summarization. Scope arguments choose field-definition
241
- visibility, not host authorization, just as with typed sorting.
242
-
243
- Lists and grouped-count hashes default to 100 values and accept a positive
244
- Integer `limit:` up to 1,000. They are truncated in value order, not ranked by
245
- frequency. Compare their length with `count_distinct_typed_eav_values` to
246
- detect truncation; that exact SQL count includes one NULL category and returns
247
- only an Integer, regardless of cardinality. It still requires database work
248
- over the matching set. Collection/multi-cell fields and all-partitions mode
249
- are unsupported, matching scalar sorting.
250
-
251
- ### Numeric aggregates
252
-
253
- ```ruby
254
- Contact.where(tenant_id: "t1").aggregate_typed_eav(
255
- "score", operation: :sum, scope: "t1"
256
- )
257
- ```
258
-
259
- `aggregate_typed_eav` requires `operation: :min`, `:max`, or `:sum` and returns
260
- one SQL-calculated scalar over the caller's host set. Integer fields return
261
- Integers; Decimal and Percentage fields preserve `BigDecimal` precision, with
262
- no Float conversion. Percentage values remain stored fractions, not formatted
263
- percent strings. Missing rows and explicit NULLs are ignored. With no non-NULL
264
- values, min/max return `nil` and sum returns the field's typed zero.
265
-
266
- The same host filtering, partition visibility, STI, and pagination rules as
267
- distinct queries apply. Only Integer/Decimal families (including Percentage)
268
- with a single numeric cell are supported. Reference IDs, text, collections,
269
- and multi-cell Currency are rejected; the gem does not silently sum identifiers
270
- or combine currencies. No values or host records are loaded to compute the result.
271
-
272
- ### Available Operators
273
-
274
- | Operator | Works On | Description |
275
- |----------|----------|-------------|
276
- | `:eq` | all | Equal (default) |
277
- | `:not_eq` | all | Not equal (NULL-safe) |
278
- | `:gt` | numeric, date, datetime | Greater than |
279
- | `:gteq` | numeric, date, datetime | Greater than or equal |
280
- | `:lt` | numeric, date, datetime | Less than |
281
- | `:lteq` | numeric, date, datetime | Less than or equal |
282
- | `:between` | numeric, date, datetime | Between (pass Range or Array) |
283
- | `:contains` | text, long_text | ILIKE %value% |
284
- | `:not_contains` | text, long_text | NOT ILIKE %value% |
285
- | `:starts_with` | text, long_text | ILIKE value% |
286
- | `:ends_with` | text, long_text | ILIKE %value |
287
- | `:any_eq` | json arrays | Array contains element |
288
- | `:all_eq` | json arrays | Array contains all elements |
289
- | `:is_null` | all | Value is NULL |
290
- | `:is_not_null` | all | Value is not NULL |
291
-
292
- ### Optional trigram indexing for string search
293
-
294
- TypedEAV keeps its partial-covering `text_pattern_ops` B-tree as the default
295
- string index. Equality uses that B-tree, while `:starts_with`, `:contains`, and
296
- `:ends_with` use `ILIKE`; `:not_contains` uses `NOT ILIKE`. The gem does not
297
- require or install `pg_trgm` and does not create a trigram index automatically.
298
-
299
- An application with frequent positive `ILIKE` searches containing at least
300
- three useful characters may evaluate its own partial GIN index. This is a
301
- workload decision: the representative benchmark used GIN for measured prefix,
302
- contains, suffix, and escaped-literal patterns, but not for `NOT ILIKE` or
303
- one/two-character probes. It does not prove that every positive pattern or
304
- selectivity will benefit. A `lower(string_value) LIKE ...` expression index is
305
- not equivalent to TypedEAV's public `ILIKE`, and the benchmark did not justify
306
- GiST.
307
-
308
- Application owners should check extension availability and deploy-role
309
- privileges in preproduction, then create the extension and index in their own
310
- migrations. Use nontransactional `CREATE INDEX CONCURRENTLY`, a stable
311
- application-specific name, and workload-specific `EXPLAIN (ANALYZE, BUFFERS,
312
- WAL, SETTINGS)` plus storage and write-WAL measurements. Rollback should drop
313
- only the application-owned index concurrently; do not drop the database-wide
314
- extension because other objects may share it. See
315
- [ADR 0009](docs/adr/0009-string-search-indexing.md) and the
316
- [benchmark guide](bench/README.md#phase-3-string-search-benchmark) for the
317
- operator matrix, measured costs, SQL, and evidence limits.
318
-
319
- ### Optional planner statistics for correlated field/value predicates
320
-
321
- TypedEAV does not install PostgreSQL extended-statistics objects. An application
322
- whose own plans persistently misestimate `field_id = ... AND typed_value = ...`
323
- may evaluate application-owned `dependencies` statistics for that exact typed
324
- column. Dependency statistics apply to compatible equality and `IN` clauses,
325
- not range predicates. `mcv` describes common value combinations, while
326
- `ndistinct` primarily informs distinct-group estimates; neither should be added
327
- without workload evidence.
328
-
329
- The representative PostgreSQL 17 benchmark found better aggregate equality
330
- estimates from dependencies, but no plan-shape or demonstrated runtime benefit.
331
- Its combined object mirrored MCV on the four changed probes because matching MCV
332
- groups supplied those estimates. The experiment's target of 100 was a controlled
333
- input, not a universal recommendation. One probe labeled common-date equality
334
- actually queried an absent date and returned zero rows; it is not evidence about
335
- common-date estimates.
336
-
337
- Applications should own stable names and DDL, select targets from representative
338
- data, run `ANALYZE`, and compare estimated/actual rows, plans, runtime, planning
339
- cost, maintenance cost, and data churn before retaining an object. Coordinate
340
- ownership in shared databases, inspect catalog definitions before changing
341
- objects, and drop only application-owned statistics during rollback. See
342
- [ADR 0010](docs/adr/0010-planner-statistics-policy.md) and the
343
- [benchmark guide](bench/README.md#phase-4a-planner-extended-statistics) for safe
344
- evaluation SQL and evidence limits.
345
-
346
- ### Multi-filter query strategy
347
-
348
- TypedEAV retains its current multi-filter query shape: it resolves each field,
349
- builds the corresponding typed value subquery, and chains those results onto
350
- the host relation with `id IN (...)`. There is no adaptive strategy or alternate
351
- production query API.
352
-
353
- A PostgreSQL 17 benchmark compared the shipped shape with `INTERSECT`,
354
- correlated `EXISTS`, and direct grouped `HAVING` under resource-capped
355
- co-tenancy. The run retained 2,940 attempts, including 622 right-censored
356
- timeouts, and 294 representative identity oracles. Twelve oracles timed out, so
357
- representative equivalence is unproved even though all 282 completed oracles
358
- matched and the smaller 98-oracle smoke matched. Alternatives remain
359
- research-only. Grouped `HAVING` is additionally ineligible for missing-value,
360
- host-universe complement, and empty-filter semantics.
361
-
362
- The result also does not establish valid buffer comparisons or 20-distinct-
363
- field scaling. A parser defect made every derived buffer total a false zero;
364
- nonzero counters remain recoverable from the retained raw plans. The
365
- 20-predicate workloads repeat ten fields, and the skewed 10/20 workloads repeat
366
- five. Future research must repair and validate buffer extraction, exercise
367
- actual 10/20 distinct fields, complete every representative equivalence oracle,
368
- cover the full scope/NULL/missing/polymorphic/error contract, and show the
369
- pre-registered p95, planning-time, buffer, and plan-shape gates before any
370
- adaptive or replacement proposal. See
371
- [ADR 0011](docs/adr/0011-multi-filter-query-strategy.md) and the
372
- [benchmark guide](bench/README.md#phase-4b-multi-filter-query-shapes).
373
-
374
- ### How Type Inference Works
375
-
376
- The owning Field casts and validates query operands before SQL generation;
377
- Active Record supplies the SQL bind plumbing:
378
-
379
- ```ruby
380
- # The Integer Field casts and validates the operand before SQL generation
381
- Contact.with_field("age", :gt, "21")
382
- # SQL: WHERE integer_value > 21 (not '21')
383
-
384
- # The Date Field owns date parsing and validation
385
- Contact.with_field("birthday", :lt, "2000-01-01")
386
- # SQL: WHERE date_value < '2000-01-01'::date
387
-
388
- # The Boolean Field owns truthy/falsy casting
389
- Contact.with_field("active", "true")
390
- # SQL: WHERE boolean_value = TRUE
391
- ```
392
-
393
- Field-owned casting keeps query operands aligned with write semantics, including strict range/array shapes and specialized fields such as Currency and Reference. The resulting normalized operand is bound against the Field's typed column; Active Record supplies SQL bind plumbing, not the field's domain semantics.
78
+ Fields cast and validate both assigned values and query operands. Queries
79
+ return Active Record relations, so you can combine them with ordinary host
80
+ filters. Use `Contact.polymorphic_name` when creating definitions to respect
81
+ Rails' STI and namespaced-polymorphism settings.
394
82
 
395
- ## Forms
396
-
397
- Wire typed fields into Rails forms via nested attributes:
398
-
399
- ```erb
400
- <%= form_with model: @contact do |f| %>
401
- <%= f.text_field :name %>
402
-
403
- <%= render_typed_value_inputs(form: f, record: @contact) %>
404
-
405
- <%= f.submit %>
406
- <% end %>
407
- ```
408
-
409
- The helper emits one input per available field, including the hidden `id` / `field_id` markers required by `accepts_nested_attributes_for`. Permit the nested shape in your controller — the `value: []` form is required for array/multi-select types:
410
-
411
- ```ruby
412
- def contact_params
413
- params.require(:contact).permit(
414
- :name,
415
- typed_values_attributes: [
416
- :id, :field_id, :_destroy, :value, { value: [] }
417
- ]
418
- )
419
- end
420
- ```
421
-
422
- For list pages, preload the field association to avoid N+1:
423
-
424
- ```ruby
425
- @contacts = Contact.includes(typed_values: :field).all
426
- ```
427
-
428
- ## Admin Scaffold
429
-
430
- To manage field definitions through a UI, run the scaffold generator:
431
-
432
- ```bash
433
- bin/rails g typed_eav:scaffold
434
- bin/rails db:migrate
435
- ```
436
-
437
- This copies a controller, views, helper, Stimulus controllers, and an initializer into your app, and adds routes mounted at `/typed_eav_fields`.
438
-
439
- **Security**: the generated controller ships with `authorize_typed_eav_admin!` returning `head :not_found` by default — fail-closed. Edit the method directly in `app/controllers/typed_eav_controller.rb` to wire it to your auth system:
440
-
441
- ```ruby
442
- def authorize_typed_eav_admin!
443
- return if current_user&.admin?
444
- head :not_found
445
- end
446
- ```
447
-
448
- Defining `authorize_typed_eav_admin!` in `ApplicationController` does **not** override it — the scaffold sets it on its own controller.
449
-
450
- ## Multi-Tenant Scoping
451
-
452
- Field definitions are partitioned by a `scope` column so multiple tenants (or accounts, workspaces, orgs — any partition key your app uses) can each define their own fields without collisions. Fields with `scope = NULL` are global, visible to every partition.
453
-
454
- ### Declaring a scoped model
455
-
456
- ```ruby
457
- class Contact < ApplicationRecord
458
- has_typed_eav scope_method: :tenant_id
459
- end
460
- ```
461
-
462
- `scope_method:` names an instance method on your model. When the record reads its own field definitions (e.g., in a form), that method tells TypedEAV which partition the record belongs to.
463
-
464
- ### Class-level queries resolve scope automatically
465
-
466
- Queries like `Contact.where_typed_eav(...)` consult an **ambient scope resolver** — no need to pass `scope:` on every call:
467
-
468
- ```ruby
469
- # The resolver tells TypedEAV which partition is active.
470
- Contact.where_typed_eav({ name: "age", op: :gt, value: 21 })
471
- ```
472
-
473
- The resolver chain (highest priority first):
474
-
475
- 1. Explicit `scope:` keyword argument on the query
476
- 2. Active `TypedEAV.with_scope(value) { ... }` block
477
- 3. Configured `TypedEAV.config.scope_resolver` callable
478
- 4. `nil`
479
-
480
- If every step returns `nil` and the model declared `scope_method:`, queries raise `TypedEAV::ScopeRequired` — the **fail-closed default**. This is the whole point: forgetting to set scope can't silently leak other partitions' data.
481
-
482
- ### Wiring the resolver
483
-
484
- Pick the pattern that matches your app and set it once in `config/initializers/typed_eav.rb`:
485
-
486
- ```ruby
487
- TypedEAV.configure do |c|
488
- # acts_as_tenant (auto-detected — no config needed if loaded)
489
- # c.scope_resolver = -> { ActsAsTenant.current_tenant&.id }
490
-
491
- # Rails CurrentAttributes
492
- # c.scope_resolver = -> { Current.account&.id }
493
-
494
- # Custom class
495
- # c.scope_resolver = -> { MyApp::Tenancy.current_workspace_id }
496
-
497
- # Subdomain / session / thread-local
498
- # c.scope_resolver = -> { Thread.current[:org_id] }
499
-
500
- # Disable ambient resolution entirely
501
- # c.scope_resolver = nil
502
-
503
- c.require_scope = true # fail-closed (default). Set false for gradual adoption.
504
- end
505
- ```
506
-
507
- The resolver MUST return a 2-element Array `[scope, parent_scope]`. Each slot
508
- accepts a raw value (`"t1"`, `42`), an AR record (TypedEAV calls `.id.to_s`
509
- on anything that responds to `#id`), or `nil`. If you don't use parent_scope,
510
- return `[scope, nil]`. A bare scalar return raises `ArgumentError` at the
511
- next ambient query — see [Migrating from v0.1.x](#migrating-from-v01x) for
512
- the upgrade path.
513
-
514
- ### Block APIs
515
-
516
- ```ruby
517
- # Run a block with a specific ambient scope (background jobs, console, rake tasks):
518
- TypedEAV.with_scope(tenant_id) do
519
- Contact.where_typed_eav({ name: "status", op: :eq, value: "active" })
520
- end
521
-
522
- # Escape hatch for admin tools, migrations, or cross-tenant audits:
523
- TypedEAV.unscoped do
524
- Contact.where_typed_eav({ name: "status", op: :eq, value: "active" })
525
- # returns matches across ALL partitions
526
- end
527
- ```
528
-
529
- Both are exception-safe via `ensure` and nest cleanly.
530
-
531
- `unscoped` is an explicit administrative/analytics escape hatch, not the
532
- ordinary tenant request path. It keeps every same-name definition across the
533
- visible partitions and unions their matches for each filter. For broad audits
534
- or migrations, bound the definition universe to the work you actually need and
535
- batch the job at an application-owned boundary. TypedEAV does not prescribe a
536
- universal limit or batch size; measure generated SQL, planning/execution,
537
- memory, and workload interference in your application. Keep normal request
538
- traffic on scoped resolution so global, scope-only, and full-tuple definitions
539
- collapse to the most-specific match.
540
-
541
- ### Explicit `scope:` override
542
-
543
- Any query method accepts `scope:` as an override for admin tools and tests:
544
-
545
- ```ruby
546
- Contact.where_typed_eav({ name: "status", value: "active" }, scope: "t1")
547
- Contact.with_field("age", :gt, 21, scope: "t1")
548
- ```
549
-
550
- Explicit wins over ambient. Passing `scope: nil` explicitly (as opposed to omitting the kwarg) means "filter to global fields only" — useful for admin UIs that want to see unscoped field definitions without activating `unscoped` mode.
551
-
552
- ### Background jobs
553
-
554
- ActiveJob (including Sidekiq via the ActiveJob adapter) wraps every `perform` in Rails' executor, which already clears `ActiveSupport::CurrentAttributes` between jobs — so if your resolver reads from `Current.account`, each job starts clean. For raw `Sidekiq::Job` (no ActiveJob), wrap the job body manually:
555
-
556
- ```ruby
557
- class ExportJob
558
- include Sidekiq::Job
559
-
560
- def perform(tenant_id, ...)
561
- TypedEAV.with_scope(tenant_id) do
562
- Contact.where_typed_eav(...)
563
- end
564
- end
565
- end
566
- ```
567
-
568
- ### Disabling enforcement for gradual adoption
569
-
570
- If your app has existing typed-eav queries that don't yet pass scope, flip `require_scope` to `false` in the initializer. When no scope resolves, queries fall back to **global fields only** (definitions stored with `scope: nil`) instead of raising — they do **not** return all partitions' fields. Audit and fix callers, then flip back to `true`.
571
-
572
- 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`.
573
-
574
- ### Two-level scoping (`parent_scope`)
575
-
576
- When a single tenant axis isn't enough — say, `tenant_id` for the customer AND
577
- `workspace_id` for an in-tenant partition — declare both:
578
-
579
- ```ruby
580
- class Project < ApplicationRecord
581
- has_typed_eav scope_method: :tenant_id, parent_scope_method: :workspace_id
582
- end
583
- ```
584
-
585
- Field (and section) definitions partition on the tuple `(entity_type, scope,
586
- parent_scope)`. A `Project` record reads field definitions in three precedence
587
- layers: a full-triple `(scope, parent_scope)` match wins, then `(scope, nil)`
588
- (tenant-wide), then `(nil, nil)` (truly global). The same precedence applies
589
- to the class-level query path.
590
-
591
- `parent_scope_method:` requires `scope_method:` — declaring it without a scope
592
- method raises at macro-expansion time (no host can have a parent partition
593
- without a scope partition).
594
-
595
- Both `with_scope` and the configured `scope_resolver` carry the tuple now:
596
-
597
- ```ruby
598
- TypedEAV.with_scope(["t1", "w1"]) do
599
- Project.where_typed_eav({ name: "status", value: "active" })
600
- end
601
-
602
- # Single-axis call still works (parent_scope = nil):
603
- TypedEAV.with_scope("t1") do
604
- Contact.where_typed_eav({ name: "age", op: :gt, value: 21 })
605
- end
606
-
607
- # Custom resolver — MUST return [scope, parent_scope]:
608
- TypedEAV.configure do |c|
609
- c.scope_resolver = -> { [Current.tenant&.id, Current.workspace&.id] }
610
- end
611
- ```
612
-
613
- Per-query overrides accept `parent_scope:` alongside `scope:` on
614
- `where_typed_eav`, `with_field`, and `typed_eav_definitions`:
615
-
616
- ```ruby
617
- Project.where_typed_eav(
618
- { name: "priority", value: "high" },
619
- scope: "t1",
620
- parent_scope: "w1",
621
- )
622
- ```
623
-
624
- When `acts_as_tenant` is loaded, the auto-detected `DEFAULT_SCOPE_RESOLVER`
625
- returns `[ActsAsTenant.current_tenant, nil]` — the parent_scope slot is `nil`
626
- because the tenant gem has no parent-scope analog. Configure your own resolver
627
- when you need both axes.
628
-
629
- ### Migrating from v0.1.x
630
-
631
- The resolver-callable contract is a **breaking change**: any custom
632
- `Config.scope_resolver` lambda must now return `[scope, parent_scope]` (a
633
- 2-element Array) instead of a bare scalar. A scalar return raises
634
- `ArgumentError` at the next ambient query so the failure is loud, not silent.
635
- If you don't use parent_scope, return `[scope, nil]`.
636
-
637
- Run `bin/rails typed_eav:install:migrations` to copy the new
638
- `AddParentScopeToTypedEavPartitions` migration into your app, then
639
- `bin/rails db:migrate`. The migration is safe on production: it adds a
640
- nullable `parent_scope` column (catalog-only, instantaneous) and uses
641
- `CREATE INDEX CONCURRENTLY` for all index changes, so existing rows aren't
642
- rewritten. Existing fields end up with `parent_scope = NULL` (the
643
- global-parent shape) and continue to work for every single-scope caller.
644
-
645
- See the [CHANGELOG](CHANGELOG.md) for the full upgrade checklist.
646
-
647
- ### Orphan-parent invariant
648
-
649
- A `Field` or `Section` row with `parent_scope` set and `scope` blank is
650
- invalid — model-level validation rejects it on save. Reason: a "global field
651
- within one workspace" has no semantic resolution path; the row would never
652
- match any record's resolver. The paired partial unique indexes rely on this
653
- invariant.
654
-
655
- The shipped migration chain also includes
656
- `EnforceParentScopeInvariant`, which declares the database check constraints
657
- nontransactionally and validates them after its preflight, and
658
- `UsePartialCoveringScalarIndexes`, which creates the six `*_present` indexes
659
- before removing their legacy counterparts. Both migrations use
660
- `disable_ddl_transaction!`; run them through the normal migration command and
661
- do not wrap them in an application transaction.
662
-
663
- ### Name collisions across scopes
664
-
665
- 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.
666
-
667
- `TypedEAV.unscoped { Contact.where_typed_eav(...) }` OR-across every partition's matching `field_id` per filter (still AND-ing across filters), so cross-tenant audit queries see every partition's matches — they don't collapse to a single tenant.
668
-
669
- Because that administrative path constructs work for every matching
670
- definition, applications should narrow and batch high-cardinality audits rather
671
- than treating `unscoped` as tenant-request routing. No built-in numeric
672
- threshold is implied; choose operational bounds from measurements of the
673
- consuming workload. See [ADR 0012](docs/adr/0012-cross-scope-administrative-query-policy.md).
83
+ See [Reading, writing, and forms](docs/guides/usage.md) for bulk assignment,
84
+ nested attributes, form helpers, and the admin scaffold, or
85
+ [Querying typed fields](docs/guides/queries.md) for operators, multi-field
86
+ filters, sorting, distinct values, counts, and numeric aggregates.
674
87
 
675
88
  ## Field Types
676
89
 
@@ -699,1098 +112,54 @@ consuming workload. See [ADR 0012](docs/adr/0012-cross-scope-administrative-quer
699
112
  | `File` | `string_value` (signed_id) + `:attachment` has_one_attached | String (Active Storage signed_id) | `allowed_content_types`, `max_size_bytes` |
700
113
  | `Reference` | `integer_value` (FK) | Integer (target record ID) | `target_entity_type`, `target_scope` |
701
114
 
702
- ## Sections (Optional UI Grouping)
703
-
704
- ```ruby
705
- general = TypedEAV::Section.create!(
706
- name: "General Info",
707
- code: "general",
708
- entity_type: "Contact",
709
- sort_order: 1
710
- )
711
-
712
- social = TypedEAV::Section.create!(
713
- name: "Social Media",
714
- code: "social",
715
- entity_type: "Contact",
716
- sort_order: 2
717
- )
718
-
719
- TypedEAV::Field::Text.create!(
720
- name: "twitter_handle",
721
- entity_type: "Contact",
722
- section: social
723
- )
724
- ```
725
-
726
- ## Custom Field Types
727
-
728
- Override `cast(raw)` to return a `[casted_value, invalid?]` tuple.
729
- `invalid?` tells `Value#validate_value` whether to surface `:invalid`
730
- (vs `:blank`) when raw input can't be coerced. For types that never
731
- fail to coerce, always return `[value, false]`.
732
-
733
- ```ruby
734
- # app/models/fields/phone.rb
735
- module Fields
736
- class Phone < TypedEAV::Field::Base
737
- value_column :string_value
738
- operators :eq, :contains, :starts_with, :is_null, :is_not_null
739
-
740
- def cast(raw)
741
- # Strip everything but digits and +; never rejects as invalid
742
- [raw&.to_s&.gsub(/[^\d+]/, ""), false]
743
- end
744
- end
745
- end
746
-
747
- # Register it
748
- TypedEAV.configure do |c|
749
- c.register_field_type :phone, "Fields::Phone"
750
- end
751
- ```
752
-
753
- ### Family intermediate bases (extension points)
754
-
755
- `Field::Base` is the universal parent, but three intermediate family
756
- bases collapse the most common per-leaf duplication. Pick the right
757
- parent and you inherit the family's validation surface for free.
758
-
759
- - **`TypedEAV::Field::ValidatedString`** — subclass when your custom
760
- type stores in `string_value` and wants a min/max-length + regex-pattern
761
- validation surface. Inherits `value_column :string_value`,
762
- `store_accessor :options, :min_length, :max_length, :pattern`,
763
- numericality validators on `min_length` / `max_length`, a
764
- `max_gte_min_length` guard that rejects inverted bounds at field-save,
765
- and a `validate_pattern_syntax` guard that rejects bad regexes at
766
- field-save. The default `validate_typed_value(record, val)` runs
767
- `validate_length` plus `validate_pattern if pattern.present?`. Override
768
- it and call `super` to layer on a format-specific check (the built-in
769
- `Field::Email` / `Field::Url` are the canonical pattern).
770
-
771
- ```ruby
772
- class Fields::Slug < TypedEAV::Field::ValidatedString
773
- SLUG_FORMAT = /\A[a-z0-9-]+\z/
774
-
775
- def cast(raw)
776
- [raw&.to_s&.strip&.downcase, false]
777
- end
778
-
779
- def validate_typed_value(record, val)
780
- super # length + pattern from the family base
781
- record.errors.add(:value, "is not a valid slug") unless SLUG_FORMAT.match?(val.to_s)
782
- end
783
- end
784
- ```
785
-
786
- - **`TypedEAV::Field::RangeBounded`** — subclass when your custom type
787
- stores a single comparable value (numeric or temporal) constrained by
788
- a min/max bound. Each leaf still declares its own `value_column` and
789
- its own `store_accessor` (key names vary by family member: `:min`/`:max`
790
- for numeric; `:min_date`/`:max_date` for date;
791
- `:min_datetime`/`:max_datetime` for datetime). The family base
792
- provides protected `validate_range` / `validate_date_range` /
793
- `validate_datetime_range` helpers. Each leaf should pair its
794
- `store_accessor` with the macro
795
- `validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min`
796
- (or the analogous form for the leaf's key names) so inverted bounds
797
- fail at field-save.
798
-
799
- ```ruby
800
- class Fields::Score < TypedEAV::Field::RangeBounded
801
- value_column :integer_value
802
-
803
- store_accessor :options, :min, :max
804
- validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min
805
-
806
- def cast(raw)
807
- raw.nil? ? [nil, false] : [Integer(raw.to_s, exception: false), raw.to_s.empty? ? false : true]
808
- end
809
-
810
- def validate_typed_value(record, val)
811
- validate_range(record, val)
812
- end
813
- end
814
- ```
815
-
816
- - **`TypedEAV::Field::Optionable`** — `include` this concern when your
817
- custom type's valid values are drawn from a `Field::Option` set.
818
- Provides `optionable? = true`, a public-facing sorted
819
- `allowed_values` helper, and protected
820
- `validate_option_inclusion` / `validate_multi_option_inclusion`
821
- helpers. Mixin (not inheritance) because option-set field types may
822
- use different `value_column`s — the built-in `Field::Select` stores in
823
- `string_value` while `Field::MultiSelect` stores in `json_value`, and
824
- both stay as direct children of `Field::Base`.
825
-
826
- ```ruby
827
- class Fields::Tag < TypedEAV::Field::Base
828
- include TypedEAV::Field::Optionable
829
-
830
- value_column :string_value
831
- operators :eq, :not_eq, :is_null, :is_not_null
832
-
833
- def cast(raw)
834
- [raw&.to_s, false]
835
- end
836
-
837
- def validate_typed_value(record, val)
838
- validate_option_inclusion(record, val)
839
- end
840
- end
841
- ```
842
-
843
- The rule of thumb: subclass an intermediate family base when the new
844
- field type shares its storage and validation surface with the family;
845
- include `Optionable` when it draws values from an option set; subclass
846
- `Field::Base` directly (as the `Phone` example above does) when none of
847
- the family surfaces fit. `validate_array_size` lives on `Field::Base`
848
- itself — its callers span unrelated families.
849
-
850
- ### Multi-cell field types
851
-
852
- External field types may store their logical value across multiple typed
853
- columns. The entire storage surface lives directly on `Field::Base` via
854
- the `Field::TypedStorage` concern, so a custom multi-cell type is just a
855
- `Field::Base` subclass that overrides three instance methods.
856
-
857
- **Class-level DSL** (declared at class load time):
858
-
859
- - `value_column :col` – single-cell sugar; declares the primary cell.
860
- - `value_columns :a, :b, ...` – plural form for multi-cell types. The
861
- primary cell is `value_columns.first`. Both forms share storage;
862
- `value_column` and `value_columns` are interchangeable getters/setters.
863
- - `operators :eq, :gt, ...` – restrict the supported operator set.
864
- - `self.operator_column(op)` – override to route different operators to
865
- different cells. Defaults to `value_columns.first`.
866
-
867
- **Override-point instance methods** (the entire extension surface for
868
- multi-cell types):
869
-
870
- - `read_value(record)` – compose the logical value from the cells.
871
- - `write_value(record, casted)` – unpack the casted value across cells.
872
- - `apply_default(record)` – populate cells from `default_value`.
873
-
874
- The defaults target `value_columns.first`, so single-cell field types
875
- keep working without overrides. The three methods are paired – override
876
- all three or your reads will see a multi-cell shape that writes / defaults
877
- cannot produce.
878
-
879
- **Concrete snapshot helpers** (NOT overridable; derived from
880
- `value_columns`):
881
-
882
- - `value_changed?(record)` – true iff any cell saw a saved change.
883
- - `before_snapshot(record, change_type)` / `after_snapshot(record, change_type)`
884
- – per-cell hashes keyed by string column names; powers the versioning
885
- jsonb shape.
886
-
887
- Custom multi-cell type example (matches the built-in `Field::Currency`):
888
-
889
- ```ruby
890
- class Fields::Money < TypedEAV::Field::Base
891
- AMOUNT_COLUMN = :decimal_value
892
- CURRENCY_COLUMN = :string_value
893
-
894
- value_columns AMOUNT_COLUMN, CURRENCY_COLUMN
895
- operators :eq, :gt, :lt, :gteq, :lteq, :between, :currency_eq, :is_null, :is_not_null
896
-
897
- def self.operator_column(operator)
898
- operator == :currency_eq ? CURRENCY_COLUMN : AMOUNT_COLUMN
899
- end
900
-
901
- def read_value(value_record)
902
- amount = value_record[AMOUNT_COLUMN]
903
- currency = value_record[CURRENCY_COLUMN]
904
- return nil if amount.nil? && currency.nil?
905
-
906
- { amount: amount, currency: currency }
907
- end
908
-
909
- def write_value(value_record, casted)
910
- if casted.nil?
911
- value_record[AMOUNT_COLUMN] = nil
912
- value_record[CURRENCY_COLUMN] = nil
913
- else
914
- value_record[AMOUNT_COLUMN] = casted[:amount]
915
- value_record[CURRENCY_COLUMN] = casted[:currency]
916
- end
917
- end
918
-
919
- def apply_default(value_record)
920
- default = default_value
921
- return unless default.is_a?(Hash)
922
-
923
- value_record[AMOUNT_COLUMN] = default[:amount] || default["amount"]
924
- value_record[CURRENCY_COLUMN] = default[:currency] || default["currency"]
925
- end
926
- end
927
- ```
928
-
929
- The built-in `Field::Currency` is the canonical multi-cell consumer of
930
- these extension points and reads as a normal `Field::Base` subclass with
931
- exactly three method overrides.
932
-
933
- ### Built-in field types
934
-
935
- - **`Currency`:** Stores `{amount: BigDecimal, currency: String}` across two typed columns (`decimal_value` for the amount; `string_value` for the ISO 4217 currency code). Multi-cell storage is declared via `value_columns :decimal_value, :string_value`; reads, writes, and default application override `read_value`, `write_value`, and `apply_default` directly on `Field::Currency`. 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 because the snapshot helpers iterate `value_columns`. 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.
936
-
937
- ```ruby
938
- Contact.where_typed_eav(name: "price", op: :currency_eq, value: "USD")
939
- Contact.where_typed_eav(name: "price", op: :between, value: [50, 150])
940
- ```
941
-
942
- - **`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`.
943
-
944
- ```ruby
945
- pf = TypedEAV::Field::Percentage.create!(
946
- name: "discount", entity_type: "Order", scope: tenant_id,
947
- options: { display_as: :percent, decimal_places: 1 },
948
- )
949
- pf.format(BigDecimal("0.755")) # => "75.5%"
950
- ```
951
-
952
- - **`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.
953
-
954
- ```ruby
955
- field = TypedEAV::Field::Image.create!(
956
- name: "avatar", entity_type: "Contact",
957
- options: { allowed_content_types: %w[image/png image/jpeg image/webp], max_size_bytes: 5_000_000 },
958
- )
959
- value = TypedEAV::Value.create!(entity: contact, field: field)
960
- value.attachment.attach(io: file_io, filename: "avatar.png", content_type: "image/png")
961
- value.update!(string_value: value.attachment.blob.signed_id)
962
- value.value # => the signed_id String
963
- ```
964
-
965
- - **`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.
966
-
967
- - **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 (included in supported Rails versions 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`).
968
-
969
- - **`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.
970
-
971
- ```ruby
972
- TypedEAV.configure do |c|
973
- c.on_image_attached = ->(value, blob) {
974
- ProcessImageJob.perform_later(value.id, blob.id)
975
- }
976
- end
977
- ```
978
-
979
- - **`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.
980
-
981
- ```ruby
982
- rf = TypedEAV::Field::Reference.create!(
983
- name: "manager", entity_type: "Contact", scope: tenant_id,
984
- options: { target_entity_type: "Contact", target_scope: tenant_id },
985
- )
986
- TypedEAV::Value.create!(entity: alice, field: rf, value: bob) # accepts AR record
987
- TypedEAV::Value.create!(entity: alice, field: rf, value: bob.id) # accepts Integer FK
988
- Contact.where_typed_eav(name: "manager", op: :references, value: bob) # filter by record
989
- Contact.where_typed_eav(name: "manager", op: :references, value: 42) # filter by FK
990
- ```
991
-
992
- - **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`, `write_value`, `apply_default`, and `operator_column`) is the canonical way to build any future external multi-cell field type.
993
-
994
- ## Validation Behavior
995
-
996
- A few non-obvious contracts worth knowing about up front:
997
-
998
- - **Required + blank**: `required: true` fields reject empty strings, whitespace-only strings, and arrays whose every element is nil/blank/whitespace.
999
- - **Array all-or-nothing cast**: integer/decimal/date arrays mark the **whole** value invalid (stored as `nil`) when any element fails to cast. There is no silent partial — a failed form re-renders with the original input intact so the user can correct the bad element.
1000
- - **`Integer` array rejects fractional input**: `"1.9"` is rejected rather than truncated to `1`. Same rules as the scalar `Integer` field.
1001
- - **`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.
1002
- - **`TextArray` does not support `:contains`**: it backs a jsonb column where SQL `LIKE` doesn't apply. Use `:any_eq` for "array contains element".
1003
- - **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.
1004
- - **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.
1005
- - **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.
1006
- - **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.
1007
- - **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.
1008
- - **`label` is cosmetic, `name` is the machine key**: A field's optional `label` is free-text human display, independent of the slug `name`. Render via `display_name`, which returns `label` when present else `name.humanize`. `label` has no uniqueness or format constraints (only a 255-char max) and never affects ordering, lookup, partitioning, or rename detection — editing only `label` fires `on_field_change` with `:update`, never `:rename`. Existing rows (`label` NULL) render unchanged. Schema export round-trips the raw `label` (legacy payloads without a `label` key import as NULL); snapshot export carries the resolved `display_name`.
1009
-
1010
- ## Event hooks
1011
-
1012
- `typed_eav` fires `after_commit` events for value and field changes. Use them
1013
- for audit logs, search-index synchronization, cache invalidation, or any
1014
- out-of-band reaction that must wait until the database write is durable.
1015
-
1016
- ### Public callback slots
1017
-
1018
- ```ruby
1019
- TypedEAV.configure do |c|
1020
- c.on_value_change = ->(value, change_type, context) {
1021
- # change_type ∈ [:create, :update, :destroy]
1022
- # context is a frozen Hash (see `with_context` below) — read-only
1023
- }
1024
-
1025
- c.on_field_change = ->(field, change_type) {
1026
- # change_type ∈ [:create, :update, :destroy, :rename]
1027
- # NOTE: no context arg — field changes are CRUD-on-config, not
1028
- # per-entity user actions
1029
- }
1030
- end
1031
- ```
1032
-
1033
- The `:rename` change_type fires whenever the field's `name` column changed
1034
- in the just-committed save, even when bundled with other attribute changes
1035
- (options, sort_order, default_value, etc.). The detection is intentionally
1036
- escalating so any registered consumer receives a rename event whenever the
1037
- persisted name changes.
1038
-
1039
- `:update` on Value fires only when the typed value column changed. Saving
1040
- a Value record without modifying its typed column (e.g., touching only
1041
- bookkeeping columns) is a no-op for event dispatch.
1042
-
1043
- `field_dependent: :nullify` cascades produce **no** Value `:destroy`
1044
- events. The FK `ON DELETE SET NULL` runs at the database level and
1045
- bypasses AR callbacks. Only the Field `:destroy` event fires. Use
1046
- `field_dependent: :destroy` if your consumer needs per-Value events on
1047
- field deletion.
1048
-
1049
- For a persisted `field_dependent: :destroy` field with a large population,
1050
- call `field.destroy_with_values_in_batches!(batch_size: 1_000)` outside an
1051
- open transaction. The opt-in API selects only that exact `field_id` in ordered
1052
- primary-key batches, calls `Value#destroy!` for callback/version behavior, and
1053
- commits each batch independently. A retry resumes from the remaining rows. The
1054
- Field is retained until a locked, bounded residual drain proves zero rows, then
1055
- its ordinary callback-preserving `destroy!` runs. The API rejects unsaved or
1056
- non-destroy fields, open transactions, invalid batch sizes, and mismatched
1057
- connection pools. Existing `destroy`/`destroy!`, `:nullify`, and `:restrict`
1058
- behavior is unchanged.
1059
-
1060
- ### Thread-local context with `with_context`
1061
-
1062
- ```ruby
1063
- TypedEAV.with_context(request_id: request.uuid, actor_id: current_user.id) do
1064
- contact.update!(typed_eav: { phone: "555-1234" })
1065
- # on_value_change receives { request_id: "...", actor_id: 42 } as context
1066
- end
1067
- ```
1068
-
1069
- `with_context` is a thread-local stack with shallow per-key merge:
1070
-
1071
- ```ruby
1072
- TypedEAV.with_context(request_id: "abc") do
1073
- TypedEAV.with_context(source: :bulk) do
1074
- # current context: { request_id: "abc", source: :bulk }
1075
- end
1076
- # current context: { request_id: "abc" }
1077
- end
1078
- # current context: {}
1079
- ```
1080
-
1081
- The current-context hash is frozen — callbacks cannot mutate it. Outer
1082
- context is restored on exit even if the inner block raises.
1083
-
1084
- `TypedEAV.current_context` returns the current frozen Hash (or a shared
1085
- frozen `{}` when no `with_context` block is active). It's safe to call
1086
- from any code path; it never returns nil.
1087
-
1088
- ### Error policy
1089
-
1090
- User callbacks (`Config.on_value_change`, `Config.on_field_change`) are
1091
- rescued — exceptions are logged via `Rails.logger.error` and **do not
1092
- propagate** to the user's save call. The save row is already committed
1093
- when `after_commit` fires; re-raising would surface a misleading
1094
- "save failed" error.
1095
-
1096
- This is the deliberate split with first-party features. Internal
1097
- observers used by `typed_eav` itself follow a different rule: their exceptions
1098
- **propagate**. Transactional version-writing errors are separate: they
1099
- propagate inside and roll back the source transaction.
1100
-
1101
- ### Ordering guarantee
1102
-
1103
- When multiple subscribers are registered, they fire in this order:
1104
-
1105
- 1. First-party generic observers, in registration order. Errors propagate.
1106
- 2. The user proc on `Config.on_value_change` / `Config.on_field_change`,
1107
- last. Errors are rescued and logged.
1108
-
1109
- Reassigning `Config.on_value_change` after gem initialization does **not**
1110
- disable internal subscribers — they live on a separate dispatcher list
1111
- and survive `Config.reset!`.
1112
-
1113
- ### Test isolation
1114
-
1115
- Test files that exercise event hooks should opt in to the `:event_callbacks`
1116
- metadata:
1117
-
1118
- ```ruby
1119
- RSpec.describe "my feature", :event_callbacks do
1120
- it "fires the hook" do
1121
- captured = []
1122
- TypedEAV::Config.on_value_change = ->(v, t, _ctx) { captured << [v.id, t] }
1123
- contact.update!(typed_eav: { phone: "555-1234" })
1124
- expect(captured).to include([be_a(Integer), :update])
1125
- end
1126
- end
1127
- ```
1128
-
1129
- The `:event_callbacks` around hook in `spec/spec_helper.rb` snapshots and
1130
- restores Config user procs and the internal-subscriber lists around each
1131
- example, so test mutations don't leak across examples and engine-load
1132
- registrations from later phases stay intact.
1133
-
1134
- Integration specs that create real AR records and need `after_commit` to
1135
- fire durably should additionally opt in to `:real_commits`:
1136
-
1137
- ```ruby
1138
- RSpec.describe "my model", :event_callbacks, :real_commits do
1139
- # ...
1140
- end
1141
- ```
1142
-
1143
- `:real_commits` disables transactional fixtures for the example and
1144
- manually deletes typed_eav rows in FK order after.
1145
-
1146
- ### Reset semantics
1147
-
1148
- | Method | What it resets |
115
+ See [Field types and validation](docs/guides/fields.md) for options, casting
116
+ rules, attachments, sections, and custom single- or multi-column field types.
117
+
118
+ ## Essential Behavior
119
+
120
+ - **Scoping selects definitions, not host records.** Apply tenant and
121
+ authorization filters to the host relation. Scoped models require scope by
122
+ default; global definitions can be overridden by more specific partitions.
123
+ See [Multi-tenant scoping](docs/guides/scoping.md).
124
+ - **Missing rows and explicit NULLs differ.** Filtering, sorting, summaries,
125
+ and reads have documented rules for each. See
126
+ [Queries](docs/guides/queries.md) and [Bulk reads](docs/guides/bulk-operations.md#bulk-reads-bulkread).
127
+ - **The admin scaffold requires authorization.** Its generated controller
128
+ returns `404` until you implement `authorize_typed_eav_admin!` directly on
129
+ that controller. See [Admin scaffold](docs/guides/usage.md#admin-scaffold).
130
+ - **Versioning is opt-in.** Audit rows share the value transaction; public
131
+ change hooks run after commit. In-memory dirty tracking is separate from
132
+ audit history. See [Events and versioning](docs/guides/events-and-versioning.md)
133
+ and [Typed-value changes](docs/guides/usage.md#in-memory-typed-value-changes).
134
+ - **Bulk write APIs have different guarantees.** The regular writer saves
135
+ hosts with callbacks and validations. Fast upsert requires explicit
136
+ acknowledgment that host saves, persistence callbacks, and versioning are
137
+ skipped. See [Bulk operations](docs/guides/bulk-operations.md).
138
+
139
+ ## Documentation
140
+
141
+ Start at the [documentation home](docs/index.md), or browse the
142
+ [public API and configuration reference](docs/reference/index.md).
143
+
144
+ | Guide | Covers |
1149
145
  |---|---|
1150
- | `TypedEAV::Config.reset!` | User procs (`on_value_change`, `on_field_change`) plus `field_types`, `scope_resolver`, `require_scope`. Does **not** clear internal subscribers. |
1151
- | `TypedEAV::EventDispatcher.reset!` | Internal subscribers only. Does **not** touch Config. |
1152
-
1153
- Production code rarely calls either they exist for test isolation and
1154
- for the rare case where a host app wants to fully unwire the gem in a
1155
- specific request lifecycle.
1156
-
1157
- ## In-memory typed-value changes
1158
-
1159
- ```ruby
1160
- contact.set_typed_eav_value("age", 41)
1161
- contact.typed_eav_changes # => {"age" => [40, 41]}
1162
- contact.save!
1163
- contact.typed_eav_changes # => {}
1164
- ```
1165
-
1166
- `typed_eav_changes` reports logical `[before, after]` pairs for pending changes
1167
- on this host's in-memory `typed_values` target. It covers named setters,
1168
- `typed_eav_attributes=`, nested `typed_values_attributes=`, association builds,
1169
- edits to target Values, and `mark_for_destruction`/nested `_destroy`. Multi-cell
1170
- fields such as Currency retain their logical shape; returned hashes, pairs,
1171
- and mutable values are copies. Same-value assignments, reversions, and logical
1172
- `nil`-to-`nil` changes are omitted, including creating/removing a NULL value row.
1173
- Invalid input reports the cast logical result without discarding validation errors.
1174
-
1175
- Failed saves retain pending state; successful saves and reload clear it. An
1176
- outer rollback follows Active Record's restored child dirty state. The API
1177
- resolves effective field names using this record's partition precedence, and
1178
- does not load all persisted Values merely to inspect an untouched host.
1179
-
1180
- After saving, `saved_typed_eav_changes` exposes the most recent successful
1181
- host save's logical pairs:
1182
-
1183
- ```ruby
1184
- contact.set_typed_eav_value("age", 42)
1185
- contact.save!
1186
- contact.saved_typed_eav_changes # => {"age" => [41, 42]}
1187
- contact.typed_eav_changes # => {}
1188
- ```
1189
-
1190
- Saved changes are available in normal host `after_save` callbacks, including
1191
- values assigned by `before_save`. Each successful save replaces the snapshot;
1192
- a no-op save replaces it with `{}`. Failed validation or a save-callback
1193
- exception preserves the previous successful snapshot. Reload and an outer
1194
- transaction rollback clear saved changes. These are successful-save semantics,
1195
- not proof of a durable commit: use `after_commit` when external effects must
1196
- wait for commit. Exceptions after a transaction has already committed cannot
1197
- undo persisted data. Neither dirty API requires versioning or adds audit rows.
1198
-
1199
- This is in-memory editing state, not audit history. Independently loaded/saved
1200
- Values, reassignment of an existing Value's field identity, SQL/`delete_all`,
1201
- and collection operations that immediately remove rows from the host target
1202
- are not tracked. Use nested destruction or `mark_for_destruction` for tracked
1203
- removal. Reduced `BulkUpsert` does not update unrelated in-memory host objects;
1204
- reload them after external writes. Bulk reads do not create dirty state.
1205
-
1206
- ## Versioning
1207
-
1208
- `typed_eav` ships an opt-in append-only audit log for changes to typed
1209
- values. When enabled, each `:create` / `:update` / `:destroy` event on
1210
- a Value writes a row to `typed_eav_value_versions` capturing the
1211
- before-state, after-state, actor, context, and timestamp.
1212
-
1213
- Default off. Apps that don't enable it pay zero overhead — transactional
1214
- Value callbacks are not installed at boot
1215
- at all when `Config.versioning = false`. Zero callable in the dispatcher
1216
- chain, zero per-write method dispatch, zero per-write config read.
1217
-
1218
- ### Enabling versioning
1219
-
1220
- Two steps:
1221
-
1222
- ```ruby
1223
- # 1. Set the gem-level master switch in an initializer.
1224
- # config/initializers/typed_eav.rb
1225
- TypedEAV.configure do |c|
1226
- c.versioning = true
1227
- c.actor_resolver = -> { Current.user } # optional; nil is permissive
1228
- end
1229
-
1230
- # 2. Opt the host model in. Either via the kwarg form:
1231
- class Contact < ApplicationRecord
1232
- has_typed_eav scope_method: :tenant_id, versioned: true
1233
- end
1234
-
1235
- # Or via the concern (equivalent — pick whichever fits your conventions):
1236
- class Contact < ApplicationRecord
1237
- has_typed_eav scope_method: :tenant_id
1238
- include TypedEAV::Versioned
1239
- end
1240
- ```
1241
-
1242
- The two opt-in forms produce identical Registry state. The kwarg form is
1243
- preferred for new code; the concern form fits codebases with established
1244
- mixin-based feature wiring.
1245
-
1246
- ### Querying history
1247
-
1248
- ```ruby
1249
- contact.typed_eav_attributes = [{ name: "age", value: 41 }]
1250
- contact.save!
1251
- contact.typed_eav_attributes = [{ name: "age", value: 42 }]
1252
- contact.save!
1253
-
1254
- value = contact.typed_values.find_by(field: age_field)
1255
- value.history # most-recent-first relation
1256
- # => [<ValueVersion change_type: "update" before: {"integer_value" => 41} after: {"integer_value" => 42}>,
1257
- # <ValueVersion change_type: "create" before: {} after: {"integer_value" => 41}>]
1258
-
1259
- value.history.first.changed_by # => "42" (User#42 — coerced to id.to_s)
1260
- value.history.first.context # => { "request_id" => "abc-123" } if with_context was active
1261
- ```
1262
-
1263
- `value.history` is a chainable relation. Filter, paginate, pluck:
1264
-
1265
- ```ruby
1266
- value.history.where(change_type: "update").pluck(:changed_at, :changed_by)
1267
- value.history.limit(5).each { |v| ... }
1268
- ```
1269
-
1270
- ### Querying full audit history (including destroy events)
1271
-
1272
- `Value#history` returns versions where `value_id` matches the live Value
1273
- record. After the live Value is destroyed, the FK `ON DELETE SET NULL`
1274
- nullifies `value_id` on the existing version rows, and the new `:destroy`
1275
- version is written by the transactional destroy callback with `value_id: nil`
1276
- before the parent row is removed. So `Value#history`
1277
- cannot surface destroy versions, and after Value destruction it can no
1278
- longer be called at all.
1279
-
1280
- To query the FULL audit history for a given (entity, field), including
1281
- destroy events and post-destruction lookup, use the entity-scoped query
1282
- directly:
1283
-
1284
- ```ruby
1285
- TypedEAV::ValueVersion
1286
- .where(entity_type: contact.class.polymorphic_name, entity_id: contact.id, field_id: age_field.id)
1287
- .order(changed_at: :desc, id: :desc)
1288
- # => [<ValueVersion change_type: "destroy" before: {"integer_value" => 42} after: {} value_id: nil>,
1289
- # <ValueVersion change_type: "update" before: {"integer_value" => 41} after: {"integer_value" => 42} value_id: nil>,
1290
- # <ValueVersion change_type: "create" before: {} after: {"integer_value" => 41} value_id: nil>]
1291
- ```
1292
-
1293
- This pattern is the canonical way to surface "what happened to this
1294
- field on this entity" across the full lifecycle, including post-destroy.
1295
- The `entity_type` + `entity_id` columns remain the durable identity even
1296
- after the parent Value row is gone, and `field_id` survives because
1297
- destroying a Value does not destroy its Field.
1298
-
1299
- For broader audit views — "show all version history across all fields
1300
- for a given entity" (e.g., admin entity-history pages, compliance
1301
- exports) — drop the `field_id` filter:
1302
-
1303
- ```ruby
1304
- TypedEAV::ValueVersion
1305
- .where(entity_type: contact.class.polymorphic_name, entity_id: contact.id)
1306
- .order(changed_at: :desc, id: :desc)
1307
- # => all version rows for every typed field on this contact, most-recent-first.
1308
- # Includes :create, :update, and :destroy events across every field the
1309
- # entity has ever had a typed value for.
1310
- ```
1311
-
1312
- The field-scoped query (with `field_id:`) is the common case for
1313
- "history of a single field"; the entity-scoped query (without `field_id:`)
1314
- is the broad-audit case for "all version history across all fields for
1315
- this entity".
1316
-
1317
- ### Version row jsonb shape
1318
-
1319
- `before_value` and `after_value` are jsonb hashes keyed by typed-column
1320
- name:
1321
-
1322
- | Field type | Snapshot shape (single key) |
1323
- |---|---|
1324
- | `text`, `email`, `url`, `color` | `{"string_value": "..."}` |
1325
- | `long_text` | `{"text_value": "..."}` |
1326
- | `integer` | `{"integer_value": 42}` |
1327
- | `decimal` | `{"decimal_value": "10.5"}` |
1328
- | `boolean` | `{"boolean_value": true}` |
1329
- | `date` | `{"date_value": "2026-05-05"}` |
1330
- | `date_time` | `{"datetime_value": "2026-05-05T12:00:00Z"}` |
1331
- | `select` | `{"string_value": "..."}` |
1332
- | `multi_select`, `*_array`, `json` | `{"json_value": [...]}` |
1333
-
1334
- Multi-cell field types (e.g., `Currency`) produce two-key snapshots:
1335
- `{"decimal_value": "99.99", "string_value": "USD"}`. The version row's
1336
- snapshot asks the field's storage contract for its cells, so new field
1337
- types get the right shape automatically.
1338
-
1339
- `{}` (empty hash) and `{"<col>": null}` are distinct semantics:
1340
-
1341
- - `{}` means **no recorded value** — typical of `before_value` on a
1342
- `:create` event, or `after_value` on a `:destroy` event.
1343
- - `{"<col>": null}` means **recorded nil** — the user explicitly
1344
- cleared the cell.
1345
-
1346
- ### Reverting
1347
-
1348
- ```ruby
1349
- target = value.history.find_by(change_type: "update")
1350
- value.revert_to(target)
1351
- # value's typed columns now match target.before_value.
1352
- # A NEW version row is written capturing the revert (append-only).
1353
- ```
1354
-
1355
- `revert_to` writes the targeted version's `before_value` columns back
1356
- via `self[col] = …` and `save!`. The transactional version callback writes a
1357
- NEW version row whose
1358
- `after_value` reflects the targeted version's `before_value`. The
1359
- audit log is append-only — every revert is itself versioned.
1360
-
1361
- To record the intent of the revert, wrap the call in `with_context`:
1362
-
1363
- ```ruby
1364
- TypedEAV.with_context(reverted_from_version_id: target.id, actor: current_user) do
1365
- value.revert_to(target)
1366
- end
1367
- # The new version row's `context` column captures both keys.
1368
- ```
1369
-
1370
- `revert_to` raises `ArgumentError` in three documented conditions, checked in order:
1371
-
1372
- - when `version.value_id` is nil (the source Value was destroyed — destroy
1373
- versions have `value_id: nil` per the locked subscriber contract; you
1374
- can't restore a destroyed AR record by `save!`);
1375
- - when the version's `before_value` is empty (the version represents a
1376
- `:create` event with no before-state to revert to);
1377
- - when the version belongs to a different Value (`value_id` mismatch).
1378
-
1379
- In practice only `:update` versions are revertable. To restore a
1380
- destroyed entity's typed values, create a new `TypedEAV::Value` record
1381
- manually using `version.before_value` as the seed state.
1382
-
1383
- ### Hook ordering guarantee
1384
-
1385
- Versioning is installed as boot-latched transactional callbacks on `Value`,
1386
- and the public callback remains an after-commit observer. The version row is
1387
- written in the source transaction.
1388
- ```
1389
- Value#save! → transactional Value callback → ValueVersion.create!
1390
- → after_commit → EventDispatcher.dispatch_value_change:
1391
- 1. ... any other generic internal observers ...
1392
- 2. Config.on_value_change user proc # sees the persisted version
1393
- ```
1394
-
1395
- Internal observer errors propagate. Transactional version-writing errors also
1396
- propagate inside and roll back the source transaction.
1397
- User proc errors are rescued and logged via `Rails.logger.error` —
1398
- the save itself already committed.
1399
-
1400
- ### Actor resolution
1401
-
1402
- `Config.actor_resolver` mirrors `Config.scope_resolver`'s callable shape
1403
- but returns whatever the app chooses (an AR record, a string, an integer,
1404
- nil). The subscriber coerces non-nil returns via `id.to_s` (for AR
1405
- records) or `to_s` (for scalars) before storing in the `changed_by`
1406
- column (string, nullable).
1407
-
1408
- `nil` is the documented permissive sentinel: system writes, migrations,
1409
- console-without-actor, and background jobs without a `with_context(actor:
1410
- ...)` wrap all flow through with `changed_by: nil`. This is intentional —
1411
- forcing every Versioned write to have an actor would reject every console
1412
- save and every migration backfill, which is hostile-by-default for a gem.
1413
-
1414
- Apps that need stricter enforcement do it inside the resolver:
1415
-
1416
- ```ruby
1417
- c.actor_resolver = -> { Current.user || raise(MyApp::ActorRequired) }
1418
- ```
1419
-
1420
- `Config.reset!` (documented in §"Event hooks") also resets `Config.versioning`
1421
- to `false` and `Config.actor_resolver` to `nil`.
1422
-
1423
- ### What versioning does not do
1424
-
1425
- - **No branching/merging across version chains.** Phase 4 ships event-log
1426
- shape only. Roadmap explicitly defers branching to a future design.
1427
- - **No snapshot storage by default.** `typed_eav_value_versions` is an
1428
- event log — one row per change, not a full-row snapshot. For
1429
- high-volume apps that want snapshot storage, extend `ValueVersion` in
1430
- your own code (the gem keeps the event-log shape canonical so future
1431
- upgrades don't break your extension).
1432
- - **No automatic `reverted_from_version_id` injection.** Use
1433
- `with_context` to record revert intent; the gem captures whatever
1434
- context the caller set.
1435
- - **No per-Field versioning toggle.** Opt-in is per-entity (host model)
1436
- in Phase 4. Per-field granularity may land later if a real need
1437
- surfaces.
1438
- - **No GIN indexes on `before_value` / `after_value` content.** Apps
1439
- that need to query inside the snapshot jsonb add their own indexes.
1440
- Phase 4 ships only the temporal indexes (`changed_at DESC` keyed on
1441
- `value_id`, `(entity_type, entity_id)`, and `field_id`).
1442
-
1443
- ### Test isolation
1444
-
1445
- Specs that exercise versioning should opt into the `:event_callbacks`
1446
- and `:real_commits` metadata flags (see §"Event hooks" — same pattern):
1447
-
1448
- ```ruby
1449
- RSpec.describe "my versioning behavior", :event_callbacks, :real_commits do
1450
- before do
1451
- TypedEAV.registry.register("Contact", versioned: true)
1452
- TypedEAV::Config.versioning = true
1453
- # Transactional Value callbacks are boot-latched and remain installed;
1454
- # the hook isolates only public and generic EventDispatcher observers.
1455
- end
1456
- after { TypedEAV.registry.register("Contact", versioned: false) }
1457
-
1458
- it "writes a version row" do
1459
- # ...
1460
- end
1461
- end
1462
- ```
1463
-
1464
- The `:event_callbacks` around hook in `spec/spec_helper.rb` snapshots and
1465
- restores `Config.versioning`, `Config.actor_resolver`, and generic
1466
- EventDispatcher observer lists around each example. Transactional Value
1467
- callback installation is tested independently through callback-chain and
1468
- boot-latch specs. The
1469
- `:real_commits` hook disables transactional fixtures (so `after_commit`
1470
- fires durably) and cleans up `TypedEAV::ValueVersion` rows in
1471
- FK-respecting order between examples.
1472
-
1473
- ## Database Support
1474
-
1475
- 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.
1476
-
1477
- 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.
1478
-
1479
- ## Schema
1480
-
1481
- The gem creates five tables:
1482
-
1483
- - `typed_eav_fields` - field definitions (STI, one row per field per entity type)
1484
- - `typed_eav_values` - values (one row per entity per field, with typed columns)
1485
- - `typed_eav_options` - allowed values for select/multi-select fields
1486
- - `typed_eav_sections` - optional UI grouping
1487
- - `typed_eav_value_versions` - opt-in, append-only audit history for Value
1488
- create, update, and destroy events; it retains durable entity identity even
1489
- when the live Value row is later removed
1490
-
1491
- ## Read-only schema previews
1492
-
1493
- ```ruby
1494
- schema = TypedEAV::SchemaPortability.export_schema(
1495
- entity_type: "Contact", scope: "t1"
1496
- )
1497
- schema["fields"].first["required"] = true
1498
- preview = TypedEAV::SchemaPortability.preview_schema(schema, on_conflict: :overwrite)
1499
- preview["fields"].first["changes"]
1500
- # => {"required" => {"from" => false, "to" => true}}
1501
- preview["risks"] # => ["required_false_to_true"]
1502
- ```
1503
-
1504
- The preview compares a version-1 portable export with the database's exact
1505
- target partition. It requires the envelope's `entity_type`, `scope`, and
1506
- `parent_scope` to match every entry; mixed-target payloads and duplicate
1507
- identities are rejected. This intentionally stricter preview input does not
1508
- change `import_schema` or silently retarget definitions.
1509
-
1510
- The plain Hash result contains envelope metadata, `summary`, `fields`,
1511
- `sections`, `risks`, and `importable`. Each entry includes its exact `identity`,
1512
- `status` (`unchanged`, `added`, `changed`, or `conflict`), conditional `action`,
1513
- and attribute `changes` with `from`/`to` values. Field entries also contain
1514
- option-row `added`, `removed`, and `changed` lists, matched by option value.
1515
- Raw option ordering and key-presence differences remain visible because the
1516
- importer compares the complete exported payload, not just equivalent settings.
1517
-
1518
- `on_conflict: :error` blocks divergent definitions; `:skip` leaves them alone;
1519
- `:overwrite` predicts an update. Type swaps always produce an error action,
1520
- even under skip/overwrite. Risks flag type changes, removed options, newly
1521
- required fields, and changes to options, defaults, or field dependencies.
1522
- Omitted target definitions are **not deletions** and are not listed as such.
1523
-
1524
- `importable: true` means no known conflict-policy/type-swap blocker was found,
1525
- not that validation or a later import is guaranteed to succeed. Actions are
1526
- conditional predictions: a blocking error aborts the existing transactional
1527
- import, including otherwise acceptable additions. Previewing does not save
1528
- definitions, run mutation/validation callbacks, enqueue jobs, execute DDL, or
1529
- convert values. It is an advisory snapshot, not a lock or reservation; model
1530
- validations and concurrent changes still apply to the actual import.
1531
-
1532
- ## Architecture
1533
-
1534
- Current internal module layout. Most consumers never reach for these directly — the public surface is the `has_typed_eav` macro and the instance/class methods it installs — but the split matters if you're extending the gem, debugging an integration, or evaluating it for production. Decisions are anchored by ADR-0001 through ADR-0013.
1535
-
1536
- ### Macro entry: `HasTypedEav`
1537
-
1538
- `lib/typed_eav/has_typed_eav.rb` (~120 LOC) is the macro shell. When you call `has_typed_eav` on an AR model, it:
1539
-
1540
- 1. `extend`s `TypedEAV::EntityQuery` onto the class (class-level query methods).
1541
- 2. `include`s `TypedEAV::HasTypedEav::InstanceMethods` (per-record accessors).
1542
- 3. Wires scope/parent-scope kwargs into the model's class-level configuration.
1543
- 4. Registers the model with `TypedEAV::Registry`.
1544
-
1545
- The macro is intentionally thin. All real behavior lives in the modules it pulls in.
1546
-
1547
- ### Class-level reads: two-altitude query pattern
1548
-
1549
- ```
1550
- Contact.where_typed_eav(...) ← public class method
1551
-
1552
-
1553
- TypedEAV::EntityQuery ← high altitude: orchestrator
1554
- • resolves scope/parent_scope from ambient context or explicit kwargs
1555
- • owns the UNSET_SCOPE / ALL_SCOPES sentinels
1556
- • delegates to FilterQuery
1557
-
1558
-
1559
- TypedEAV::FilterQuery ← multi-filter composition
1560
- • normalizes filter input shapes (positional, hash, hash-of-hashes)
1561
- • looks up field definitions via TypedEAV::Partition
1562
- • per filter, asks QueryBuilder for the SQL fragment
1563
- • unions/intersects per-field entity-id sets
1564
- • returns an ActiveRecord::Relation scoped to the host model
1565
-
1566
-
1567
- TypedEAV::QueryBuilder ← low altitude: per-field SQL primitive
1568
- • turns a single (field, op, value) into a WHERE clause against typed_eav_values
1569
- • knows about typed-column projections (integer_value, string_value, etc.)
1570
- • knows about operator-specific column choice (currency-cents vs currency-code)
1571
- ```
1572
-
1573
- `QueryBuilder` is the single place that decides "given this field and this operator, which column and which SQL fragment?" `FilterQuery` never builds SQL fragments directly; `EntityQuery` never touches columns. Splitting the two altitudes keeps custom field types extending only the column-mapping surface (`value_column`, `operators`, `operator_column`) without ever subclassing `FilterQuery`.
1574
-
1575
- Scalar ordering and summaries are a separate `EntityQuery` delegation to
1576
- `ScalarQuery`: it resolves one winning definition, checks scalar support, and
1577
- builds SQL over the field's declared native column. It does not add operators
1578
- to the filter DSL or load the host/Value graph to calculate summaries.
1579
-
1580
- ### Bulk reads: `BulkRead`
1581
-
1582
- `typed_eav_hash_for(records)` (the plural read) routes through `TypedEAV::BulkRead`. Given a record collection and an effective `(scope, parent_scope)`, it:
1583
-
1584
- 1. Resolves visible definitions and groups the requested field IDs.
1585
- 2. Loads definitions, values, and field associations through one batched
1586
- definition query, one values query, and one field-association preload
1587
- (three SQL queries total; no host-table query).
1588
- 3. Returns a `{record_id => {field_name => value}}` map while skipping orphaned
1589
- values and preserving logical missingness.
1590
-
1591
- Definitions, filters, reads, registry entries, and writes all use the host's
1592
- Rails `polymorphic_name`, so an STI leaf class reads and queries the same rows
1593
- written under its base-class polymorphic type.
1594
-
1595
- The final production characterization reduced the 1,002 SQL statements observed
1596
- across 1,000 scopes to three for the same BulkRead shape. This is a statement-
1597
- count result, not a representative throughput claim; applications should still
1598
- measure their own scope cardinality, selected fields, hydration, and contention.
1599
-
1600
- Single-record reads (`typed_eav_value`, `typed_eav_hash`) live on `InstanceMethods` and use the same partition helpers but without batching.
1601
-
1602
- Use `fields:` to load only the values needed by a view or export:
1603
-
1604
- ```ruby
1605
- Contact.typed_eav_hash_for(contacts, fields: [:name, :score])
1606
- # => {123 => {"name" => "Ada", "score" => 42}, ...}
1607
- ```
1608
-
1609
- Omitting `fields:` (or passing `nil`) retains the all-fields behavior. A single
1610
- String/Symbol or an enumerable of names is accepted; duplicates are removed.
1611
- Unknown names and names unavailable in an individual record's partition are
1612
- omitted, as are missing value rows. An explicitly stored NULL remains `nil`.
1613
- `fields: []` returns an empty inner hash for each record without definition or
1614
- value queries (the supplied collection itself may still need loading). Selected
1615
- winning field IDs constrain the value query before hydration, so unrequested
1616
- values and their field readers are not loaded or evaluated. Definition lookup
1617
- remains batched across the records' partitions.
1618
-
1619
- To explicitly reuse already-loaded values:
1620
-
1621
- ```ruby
1622
- contacts = Contact.where(tenant_id: "t1").includes(typed_values: :field).to_a
1623
- Contact.typed_eav_hash_for(contacts, fields: [:name], source: :preloaded)
1624
- ```
1625
-
1626
- The default `source: :database` still fetches persisted values afresh, even
1627
- when associations are loaded or edited in memory. `:preloaded` uses the caller's
1628
- association targets, including unsaved Value builds/assignments, without saving
1629
- or mutating them. It performs one fresh batched definition query to choose
1630
- current winners, but no Value or field-association preload queries. This is a
1631
- value snapshot, not a guarantee of current database contents or frozen schema.
1632
-
1633
- Every host's `typed_values` association must be loaded, as must each retained
1634
- Value's `field` association; incomplete preloads raise `ArgumentError` instead
1635
- of silently issuing N+1 queries. With `fields:`, unselected Values need no field
1636
- preload. With all fields selected, all field associations must be loaded
1637
- (including loaded `nil` for orphans). `fields: []` needs neither associations
1638
- nor definition/value queries. Only `:database` and `:preloaded` are valid sources.
1639
-
1640
- ### Bulk writes: `BulkWrite`
1641
-
1642
- `bulk_set_typed_eav_values(records, attrs)` routes through `TypedEAV::BulkWrite`,
1643
- and `bulk_set_typed_eav_values_per_record(values_by_record)` is its sibling for
1644
- record-varying hashes. Both are semantic writers that:
1645
-
1646
- 1. Memoizes field definitions for the call via `Thread.current[:typed_eav_bulk_defs_memo]`.
1647
- 2. Validates each attribute against its field type's cast contract.
1648
- 3. Saves each host through the normal callback/validation path inside an outer
1649
- transaction with per-record savepoints.
1650
-
1651
- `bulk_set_typed_eav_values_per_record` uses records as Hash keys, so two AR
1652
- instances of the same persisted row collapse to one entry; sequence separate
1653
- calls for two ordered updates, while the uniform Array API preserves duplicate
1654
- instances and caller order.
1655
-
1656
- Under `transaction: :all`, per-record validation failures are captured at their
1657
- savepoints while other successes can commit, but an uncaught exception rolls
1658
- the entire outer transaction back. The default `transaction: :all` commits the
1659
- whole successful batch or rolls it back;
1660
- `transaction: :chunks, chunk_size: N` commits completed chunks while isolating
1661
- later failures, preserving earlier committed chunks. Both forms require the
1662
- host, Field, and Value pools to match.
1663
- `bulk_upsert_typed_eav_values` is a separate reduced-semantics fast path: it
1664
- casts and validates typed values, then performs one PostgreSQL upsert while
1665
- omitting host saves, persistence callbacks, delete shorthand, and versioning.
1666
-
1667
- Callers must pass `acknowledge_reduced_semantics: true`. The same values hash
1668
- applies to every record; records must be persisted and unique, and string or
1669
- symbol field keys that normalize to the same name are rejected. The return
1670
- value is the integer number of value rows upserted, not a semantic
1671
- `successes`/`errors_by_record` result. Value casting, domain/entity/partition
1672
- checks, and Value validation callbacks remain; host callbacks and validations,
1673
- Value persistence callbacks, versioning, delete shorthand, and per-record
1674
- savepoint isolation are skipped.
1675
-
1676
- Within each `transaction: :all` unit—or each requested chunk—the upsert path
1677
- resolves every record partition through one batched field-definition SELECT.
1678
- It shares BulkRead's internal tuple resolver, retaining global, scope-only, and
1679
- full-tuple precedence independently for each record without broadening tenant
1680
- visibility.
1681
-
1682
- `BulkWrite` and `BulkRead` are siblings — one read path, one write path — but they don't share a base class. Per [ADR-0005](docs/adr/0005-keep-phase-six-modules-independent.md), keeping them independent preserves the option to evolve each on its own schedule.
1683
-
1684
- ### Per-record reads/writes: `InstanceMethods`
1685
-
1686
- `lib/typed_eav/has_typed_eav/instance_methods.rb` (~250 LOC) holds the per-record API:
1687
-
1688
- - `typed_eav_value(name)` / `typed_eav_hash` — reads
1689
- - `set_typed_eav_value(name, value)` / `typed_eav_attributes=` (aliased as `typed_eav=`) — writes
1690
- - `typed_eav_definitions` — resolved field-definitions map for the host record
1691
- - `typed_eav_scope` / `typed_eav_parent_scope` — scope resolution per record
1692
-
1693
- Every method uses `TypedEAV::Partition.definitions_by_name` so the collision-precedence rules for ambient/explicit/parent scopes are computed in one place.
1694
-
1695
- ### Partition visibility: `Partition`
1696
-
1697
- Host applications that need to inspect effective schema should use the
1698
- documented-public `TypedEAV::Partition` seam rather than rebuilding tuple
1699
- predicates. It exposes `visible_fields`, `effective_fields_by_name`,
1700
- `definitions_by_name`, `definitions_multimap_by_name`, `visible_sections`,
1701
- and `find_visible_section!`. These methods preserve global, scope-only, and
1702
- full-tuple precedence; ADR-0006 additionally fixes include-missing set
1703
- composition at the `FilterQuery` altitude.
1704
-
1705
- ### Field types and storage: `Field::TypedStorage`
1706
-
1707
- `TypedEAV::Field::Base` is the STI parent of every field type. The shared storage surface lives in the `TypedEAV::Field::TypedStorage` concern (`lib/typed_eav/field/typed_storage.rb`, ~200 LOC), auto-included on `Field::Base`. Per [ADR-0001](docs/adr/0001-collapse-column-mapping-stack.md), it provides:
1708
-
1709
- - **Class DSL**: `value_column`, `value_columns`, `operators`, `operator_column`, `supported_operators` — describe where typed values live and which operators they support.
1710
- - **Instance override points**: `read_value(record)`, `write_value(record, casted)`, `apply_default(record)` — the three methods a multi-cell field type overrides.
1711
- - **Concrete snapshot helpers**: `value_changed?`, `before_snapshot`, `after_snapshot` — derived automatically from `value_columns`; not overridable.
1712
-
1713
- Custom multi-cell field types subclass `Field::Base` directly and override only the three instance methods. See §[Multi-cell field types](#multi-cell-field-types) for `Currency` as the canonical worked example.
1714
-
1715
- ### Field families: intermediate STI bases
1716
-
1717
- Per [ADR-0004](docs/adr/0004-field-family-intermediate-bases.md), three intermediate STI parents factor shared validation behavior out of `Field::Base`:
1718
-
1719
- - **`TypedEAV::Field::ValidatedString`** — parent of `Text`, `Email`, `Url`. Owns string-length and pattern-validation helpers including `max_gte_min_length` (which now covers Email/Url, not just Text).
1720
- - **`TypedEAV::Field::RangeBounded`** — parent of `Integer`, `Decimal`, `Date`, `DateTime` (and `Percentage < Decimal`). Owns range-validation helpers including `validates :max, comparison: { greater_than_or_equal_to: :min }` (which now covers Date/DateTime, not just Integer/Decimal).
1721
- - **`TypedEAV::Field::Optionable`** — a Rails concern included by `Select` and `MultiSelect`. Owns the public-facing sorted `allowed_values` reader and the option-inclusion validators.
1722
-
1723
- `Color`, `Boolean`, `Json`, and the array field types (`TextArray`, `IntegerArray`, `DecimalArray`, `DateArray`) remain direct children of `Field::Base`. See §[Family intermediate bases](#family-intermediate-bases-extension-points) for extension examples.
1724
-
1725
- ### Scope tuple normalization: `ScopeTuple`
1726
-
1727
- `TypedEAV::ScopeTuple` (`lib/typed_eav/scope_tuple.rb`, ~120 LOC) is the canonical source of truth for the `(scope, parent_scope)` partition tuple. It provides:
1728
-
1729
- - `normalize_permissive(scope)` — coerces input to a tuple; tolerates bare scalars (used by `with_scope`, `normalize_scope`, `Field#validate_parent_scope_invariant`).
1730
- - `normalize_strict(scope)` — same shape, but raises on bare-scalar input (used by `current_scope`; preserves Phase-1's asymmetric contract that `Config.scope_resolver` must return a tuple).
1731
- - `invariant_satisfied?(scope, parent_scope)` — Boolean check for the orphan-parent invariant (`parent_scope` set without `scope` = invalid).
1732
-
1733
- Each calling site keeps its own response policy (raise / AR error / silent narrow) using the Boolean return — `ScopeTuple` is a predicate, not an enforcer.
1734
-
1735
- ### Partition tuple helpers: `Partition`
1736
-
1737
- `TypedEAV::Partition` (`lib/typed_eav/partition.rb`, ~100 LOC) owns the `(entity_type, scope, parent_scope)` precedence rules:
1738
-
1739
- - `definitions_by_name(model, scope, parent_scope)` — returns the field-definitions map for a single resolved partition.
1740
- - `definitions_multimap_by_name(model)` — returns the cross-partition multimap used by `unscoped { }` blocks.
1741
- - `visible_fields(model, scope, parent_scope)` / `visible_sections(...)` — scope-respecting field/section iteration with the orphan-parent invariant inlined via `ScopeTuple.invariant_satisfied?`.
1742
-
1743
- The definitions helpers used to live as class methods on `HasTypedEav` before 0.3.0. They moved to `Partition` per [ADR-0002](docs/adr/0002-entity-query-orchestration.md) because they describe the partition domain, not the macro.
1744
-
1745
- ### Events: `EventDispatcher`
1746
-
1747
- `TypedEAV::EventDispatcher` (`lib/typed_eav/event_dispatcher.rb`, ~150 LOC) is the broker for `on_value_change` and `on_field_change` callbacks. Per [ADR-0003](docs/adr/0003-keep-event-dispatcher-broker.md), it intentionally stays a broker rather than getting absorbed into either `Value` or `Field` — its multi-publisher / multi-subscriber shape doesn't belong on either model. See §[Event hooks](#event-hooks) for the public callback contract.
1748
-
1749
- ### Schema portability and CSV: independent modules
1750
-
1751
- `TypedEAV::SchemaPortability` and `TypedEAV::CSVMapper` (Phase-6 modules) are deliberately decoupled from the core read/write path per [ADR-0005](docs/adr/0005-keep-phase-six-modules-independent.md). They depend on the public `has_typed_eav` macro surface, never on internal modules.
1752
-
1753
- ### Bulk operation guarantees
1754
-
1755
- `bulk_upsert_typed_eav_values` is an explicit reduced-semantics API: it
1756
- prevalidates/casts values and performs a PostgreSQL upsert, while intentionally
1757
- omitting host callbacks and versioning. Use the regular bulk writer when those
1758
- semantics are required; chunked semantic transactions are opt-in.
1759
-
1760
- The fast path still casts and runs domain, entity, partition, and validation
1761
- callbacks before its single upsert against the exact entity/field conflict
1762
- target; it omits host saves/host callbacks, Value persistence callbacks,
1763
- delete shorthand, and versioning. It requires one shared connection pool and
1764
- returns validation errors before SQL. `:all` is one unit; `:chunks` commits
1765
- completed chunks before a later failure. Semantic writes retain host saves,
1766
- per-record savepoint/error isolation, and one outer `:all` transaction.
1767
-
1768
- BulkWrite evidence is intentionally bounded to the exercised 100- and 1,000-host
1769
- lanes. It does not establish 10,000- or 100,000-host throughput, nor does it
1770
- justify a universal batch size or storage choice.
1771
-
1772
- ### Operational guarantees
1773
-
1774
- The semantic writer preserves the caller's transaction and callback/versioning
1775
- contract. Version rows are written in the source transaction, so a rollback
1776
- rolls back the Value mutation and its audit row together. The reduced-semantics
1777
- upsert is intentionally separate and does not claim those callbacks or audit
1778
- guarantees.
1779
-
1780
- Field deletion has a callback-preserving, keyset-batched path that locks and
1781
- destroys only the exact field's Values before bounded finalization. It scales by
1782
- bounded primary-key batches and preserves the Field if a batch fails; it is not
1783
- a claim of unbounded deletion throughput.
1784
-
1785
- ### Default backfill narrowing
1786
-
1787
- `Field::Base#backfill_default!` optionally accepts an exact-host
1788
- `ActiveRecord::Relation` to SQL-narrow eligible entities before batching. The default
1789
- all-host behavior remains unchanged; partition checks, batch transactions,
1790
- callbacks, validations, idempotence, versions, and errors remain in force.
1791
- Typed storage defines logical missingness across all declared cells, so a
1792
- partially populated multi-cell value is present while a fully empty Currency
1793
- value is missing.
146
+ | [Reading, writing, and forms](docs/guides/usage.md) | Assignment by name or ID, nested forms, admin scaffold, pending and saved changes |
147
+ | [Querying typed fields](docs/guides/queries.md) | Operators, filter composition, sorting, distinct values, counts, aggregates |
148
+ | [Multi-tenant scoping](docs/guides/scoping.md) | Resolvers, two-level partitions, precedence, background jobs, administrative queries |
149
+ | [Field types and validation](docs/guides/fields.md) | Built-in types, options, sections, attachments, custom types, validation contracts |
150
+ | [Bulk operations](docs/guides/bulk-operations.md) | Selective and preloaded reads, semantic writes, fast upsert, transactions, default backfills |
151
+ | [Events and versioning](docs/guides/events-and-versioning.md) | Callbacks, context, actor resolution, audit history, reverting |
152
+ | [CSV mapping](docs/guides/csv-import.md) | Header/index mapping, typed casting, row errors, saving mapped values |
153
+ | [Database schema and portability](docs/guides/schema.md) | Tables, PostgreSQL requirements, read-only schema previews |
154
+ | [Storage and performance](docs/guides/performance.md) | JSONB tradeoffs, optional indexes and statistics, query strategy, evidence limits |
155
+
156
+ ## Development and Upgrades
157
+
158
+ - [Architecture](docs/guides/architecture.md) and [design decisions](docs/adr/)
159
+ - [Development and test isolation](docs/guides/development.md)
160
+ - [Benchmark guide](bench/README.md)
161
+ - [Upgrading](docs/guides/upgrading.md) and [Changelog](CHANGELOG.md)
162
+ - [Release process](RELEASING.md)
1794
163
 
1795
164
  ## License
1796
165