typed_eav 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 37b913418c998e4620c90efb72a0be8c93b838a2977db0347c9198b743ced2f3
4
- data.tar.gz: 0a8a7f3a622712cc29b5738b8b0f4a54e3df3ffc56657277a69ebb956d3c46bf
3
+ metadata.gz: 026326e328ecf153d7ba72eeac001470ba67307ed17bf5cef68f52548b6abd47
4
+ data.tar.gz: 6a76dce02983e81312d67662de6837804b51e691ab0d79dbfb13cacc2b25c19c
5
5
  SHA512:
6
- metadata.gz: c5bdc06300d8bb92538c93cfe23ac3afb6bed9719a29f6f7590740e6d4f56ac14998e21b4230c58fa2eed10e89dfe053af86144f3cbcf9637049c3d5b811d90b
7
- data.tar.gz: 4760251616a62f91b7ed931dafc8b92a25653047cab8fcbd21dd027fe6353fdd2e7209a0629233306c69f77ec8eec08459854e8cc81443595d0b68e0503f4e8b
6
+ metadata.gz: 1677489ecff776e1914305db8d9681a6cb52891282c6602b3d6b12acb99b89d2263bca877606e686e64d260ecff1f5170ac0f6a733b8f7f5592447d5f8852b90
7
+ data.tar.gz: 528cbe73ee76daa4e2bb50806b1e148483a68ac9563e2b3c49a3110ee153fba00bd7a6f79612824dc018fc7d13bfcde7e81ed1b0daa67190b401320ee9ea1e5f
data/CHANGELOG.md CHANGED
@@ -7,7 +7,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- No unreleased changes.
10
+ ## [0.8.0] - 2026-09-04
11
+
12
+ New SQL-backed query APIs, selective/preloaded bulk reads, logical dirty
13
+ tracking, and read-only schema previews. Existing read defaults are preserved;
14
+ no database migrations or new runtime dependencies are required.
15
+
16
+ ### Added
17
+
18
+ - Read-only `SchemaPortability.preview_schema` compares exact-partition
19
+ portable schemas with current definitions, exposing field/section/option
20
+ differences, conditional import actions, and risk flags. Type swaps remain
21
+ blocked and omitted definitions are not treated as deletions. Previewing
22
+ never applies changes or guarantees a later import's success. (#51)
23
+ - `saved_typed_eav_changes` exposes the last successful host save's logical
24
+ changes inside `after_save` callbacks. Failed saves preserve the prior
25
+ snapshot; no-op saves, reload, and rollback have explicit lifecycle
26
+ semantics independent of audit/versioning. (#48)
27
+ - Database-backed `aggregate_typed_eav` min/max/sum for Integer and Decimal
28
+ field families, including Percentage. Decimal precision, caller host sets,
29
+ and explicit empty/NULL semantics are preserved; references and multi-cell
30
+ Currency are rejected instead of silently aggregated. (#50)
31
+ - SQL-backed distinct typed values, exact distinct counts, and bounded
32
+ per-value host counts. Caller host filters/pagination and field-definition
33
+ precedence are retained; explicit NULL, false, and empty strings have
34
+ documented semantics without host or Value hydration. (#49)
35
+ - Explicit `source: :preloaded` bulk reads reuse caller-loaded Value graphs,
36
+ including unsaved edits, with one batched definition lookup and no duplicate
37
+ Value hydration. Incomplete required preloads fail clearly; fresh database
38
+ reads remain the default. (#46)
39
+ - Pending logical typed-value changes via `typed_eav_changes`, covering
40
+ host-associated named/nested edits and marked removals without loading
41
+ untouched Value collections. Failed saves retain validation feedback and
42
+ pending changes; this API is separate from persisted audit history. (#47)
43
+ - Database-backed `order_typed_eav` for scalar fields, with explicit NULL
44
+ placement and stable primary-key tie-breaking. Caller host filters and
45
+ partition-definition precedence are preserved without Value hydration. (#44)
46
+ - Field-selective bulk reads with `typed_eav_hash_for(records, fields: names)`.
47
+ Selected winning field IDs narrow value loading before hydration; omitted
48
+ `fields:` preserves all-fields reads. Empty, duplicate, unknown, and
49
+ partition-specific names have explicit projection semantics. (#45)
50
+
51
+ ### Compatibility and usage notes
52
+
53
+ - Typed sorting and summaries select one effective field definition. Scope
54
+ arguments do not authorize or filter host records: retain tenant and access
55
+ filters on the caller's Active Record relation. All-partitions mode and
56
+ unsupported collection/multi-cell operations fail explicitly.
57
+ - Distinct lists and grouped counts default to 100 values (maximum 1,000),
58
+ ordered by value rather than frequency. Exact distinct counts include the
59
+ explicit NULL category; missing rows are omitted. Numeric min/max/sum retain
60
+ Integer/BigDecimal types and do not aggregate reference IDs or currencies.
61
+ - Preloaded reads are explicit in-memory value snapshots with a current
62
+ definition lookup; database reads remain the default. Dirty/saved changes
63
+ describe host-associated editing and save state, not durable audit history
64
+ or independently executed bulk SQL writes.
65
+ - Schema previews are advisory, exact-partition comparisons. They neither
66
+ apply changes nor reserve the schema or guarantee a later import's success.
67
+ - Local verification: 1,404 examples, 0 failures; 160 Ruby files lint-clean.
68
+ The release workflow additionally verifies the supported compatibility
69
+ matrix and the exact packaged artifact before publication.
70
+
71
+ ## [0.7.1] - 2026-09-04
72
+
73
+ A focused patch release for inherited Active Record hosts and multi-partition
74
+ bulk upserts. No new database migrations or public API changes are required.
75
+
76
+ ### Fixed
77
+
78
+ - Resolve inherited and namespaced Active Record host definitions, filters,
79
+ bulk reads, writes, and registry/versioning opt-ins through Rails'
80
+ canonical polymorphic name. STI subclasses now consistently share their
81
+ base class's EAV schema and stored values while queries retain their host
82
+ class restriction. Partition isolation and most-specific field precedence
83
+ are preserved. (#42)
84
+
85
+ ### Performance
86
+
87
+ - Batch BulkUpsert field-definition resolution into one SELECT per transaction
88
+ unit while preserving exact tuple isolation and definition precedence. A
89
+ 20-partition regression case now issues one definition SELECT instead of 20;
90
+ chunked transactions intentionally issue one per chunk. BulkRead and
91
+ BulkUpsert share the internal batched resolver. This is a query-count
92
+ improvement, not a universal throughput claim. (#43)
93
+
94
+ ### Compatibility and verification
95
+
96
+ - BulkUpsert's reduced-semantics acknowledgement, validation, transaction
97
+ boundaries, and callback/versioning behavior are unchanged.
98
+ - Regression coverage includes true STI and namespaced hosts, public
99
+ read/filter/write paths, partition isolation, definition precedence, and
100
+ all/chunk transaction behavior.
11
101
 
12
102
  ## [0.7.0] - 2026-08-18
13
103
 
@@ -580,7 +670,9 @@ worked examples.
580
670
 
581
671
  Initial release.
582
672
 
583
- [Unreleased]: https://github.com/dchuk/typed_eav/compare/v0.7.0...HEAD
673
+ [Unreleased]: https://github.com/dchuk/typed_eav/compare/v0.8.0...HEAD
674
+ [0.8.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.8.0
675
+ [0.7.1]: https://github.com/dchuk/typed_eav/releases/tag/v0.7.1
584
676
  [0.7.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.7.0
585
677
  [0.6.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.6.0
586
678
  [0.5.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.5.0
data/README.md CHANGED
@@ -119,6 +119,11 @@ tags.field_options.create!([
119
119
  ])
120
120
  ```
121
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
+
122
127
  ### 3. Set values on records
123
128
 
124
129
  ```ruby
@@ -192,6 +197,78 @@ Contact.where(company_id: 42)
192
197
  .limit(25)
193
198
  ```
194
199
 
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
+
195
272
  ### Available Operators
196
273
 
197
274
  | Operator | Works On | Description |
@@ -1077,6 +1154,55 @@ Production code rarely calls either — they exist for test isolation and
1077
1154
  for the rare case where a host app wants to fully unwire the gem in a
1078
1155
  specific request lifecycle.
1079
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
+
1080
1206
  ## Versioning
1081
1207
 
1082
1208
  `typed_eav` ships an opt-in append-only audit log for changes to typed
@@ -1157,7 +1283,7 @@ directly:
1157
1283
 
1158
1284
  ```ruby
1159
1285
  TypedEAV::ValueVersion
1160
- .where(entity_type: contact.class.name, entity_id: contact.id, field_id: age_field.id)
1286
+ .where(entity_type: contact.class.polymorphic_name, entity_id: contact.id, field_id: age_field.id)
1161
1287
  .order(changed_at: :desc, id: :desc)
1162
1288
  # => [<ValueVersion change_type: "destroy" before: {"integer_value" => 42} after: {} value_id: nil>,
1163
1289
  # <ValueVersion change_type: "update" before: {"integer_value" => 41} after: {"integer_value" => 42} value_id: nil>,
@@ -1176,7 +1302,7 @@ exports) — drop the `field_id` filter:
1176
1302
 
1177
1303
  ```ruby
1178
1304
  TypedEAV::ValueVersion
1179
- .where(entity_type: contact.class.name, entity_id: contact.id)
1305
+ .where(entity_type: contact.class.polymorphic_name, entity_id: contact.id)
1180
1306
  .order(changed_at: :desc, id: :desc)
1181
1307
  # => all version rows for every typed field on this contact, most-recent-first.
1182
1308
  # Includes :create, :update, and :destroy events across every field the
@@ -1362,9 +1488,50 @@ The gem creates five tables:
1362
1488
  create, update, and destroy events; it retains durable entity identity even
1363
1489
  when the live Value row is later removed
1364
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
+
1365
1532
  ## Architecture
1366
1533
 
1367
- Internal module layout as of 0.7.0. 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.
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.
1368
1535
 
1369
1536
  ### Macro entry: `HasTypedEav`
1370
1537
 
@@ -1405,6 +1572,11 @@ TypedEAV::QueryBuilder ← low altitude: per-field SQL primitive
1405
1572
 
1406
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`.
1407
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
+
1408
1580
  ### Bulk reads: `BulkRead`
1409
1581
 
1410
1582
  `typed_eav_hash_for(records)` (the plural read) routes through `TypedEAV::BulkRead`. Given a record collection and an effective `(scope, parent_scope)`, it:
@@ -1416,6 +1588,10 @@ TypedEAV::QueryBuilder ← low altitude: per-field SQL primitive
1416
1588
  3. Returns a `{record_id => {field_name => value}}` map while skipping orphaned
1417
1589
  values and preserving logical missingness.
1418
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
+
1419
1595
  The final production characterization reduced the 1,002 SQL statements observed
1420
1596
  across 1,000 scopes to three for the same BulkRead shape. This is a statement-
1421
1597
  count result, not a representative throughput claim; applications should still
@@ -1423,6 +1599,44 @@ measure their own scope cardinality, selected fields, hydration, and contention.
1423
1599
 
1424
1600
  Single-record reads (`typed_eav_value`, `typed_eav_hash`) live on `InstanceMethods` and use the same partition helpers but without batching.
1425
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
+
1426
1640
  ### Bulk writes: `BulkWrite`
1427
1641
 
1428
1642
  `bulk_set_typed_eav_values(records, attrs)` routes through `TypedEAV::BulkWrite`,
@@ -1459,6 +1673,12 @@ checks, and Value validation callbacks remain; host callbacks and validations,
1459
1673
  Value persistence callbacks, versioning, delete shorthand, and per-record
1460
1674
  savepoint isolation are skipped.
1461
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
+
1462
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.
1463
1683
 
1464
1684
  ### Per-record reads/writes: `InstanceMethods`