@object-ui/core 17.5.0 → 17.7.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.
Files changed (157) hide show
  1. package/CHANGELOG.md +7404 -0
  2. package/README.md +127 -26
  3. package/dist/actions/ActionEngine.d.ts +5 -3
  4. package/dist/actions/ActionEngine.js +14 -10
  5. package/dist/actions/ActionRunner.d.ts +363 -30
  6. package/dist/actions/ActionRunner.js +544 -58
  7. package/dist/actions/TransactionManager.d.ts +7 -2
  8. package/dist/actions/actionKeys.d.ts +75 -13
  9. package/dist/actions/actionKeys.js +131 -16
  10. package/dist/actions/actionResponse.js +3 -3
  11. package/dist/actions/index.d.ts +2 -0
  12. package/dist/actions/index.js +2 -0
  13. package/dist/actions/recordDelete.d.ts +118 -0
  14. package/dist/actions/recordDelete.js +112 -0
  15. package/dist/actions/recordIdParam.d.ts +77 -0
  16. package/dist/actions/recordIdParam.js +37 -0
  17. package/dist/actions/serverActionHandler.d.ts +1 -1
  18. package/dist/actions/serverActionHandler.js +1 -1
  19. package/dist/adapters/ValueDataSource.d.ts +6 -1
  20. package/dist/adapters/ValueDataSource.js +1090 -98
  21. package/dist/adapters/batchTransaction.d.ts +1 -1
  22. package/dist/adapters/batchTransaction.js +1 -1
  23. package/dist/builder/schema-builder.d.ts +38 -81
  24. package/dist/builder/schema-builder.js +33 -152
  25. package/dist/data-scope/ViewDataProvider.d.ts +1 -1
  26. package/dist/data-scope/ViewDataProvider.js +21 -1
  27. package/dist/data-scope/element-data-source.d.ts +261 -7
  28. package/dist/data-scope/element-data-source.js +342 -52
  29. package/dist/data-scope/index.d.ts +9 -4
  30. package/dist/data-scope/index.js +9 -4
  31. package/dist/evaluator/ExpressionEvaluator.d.ts +128 -1
  32. package/dist/evaluator/ExpressionEvaluator.js +226 -13
  33. package/dist/evaluator/FormulaFunctions.d.ts +0 -10
  34. package/dist/evaluator/FormulaFunctions.js +166 -27
  35. package/dist/evaluator/SafeExpressionParser.js +0 -4
  36. package/dist/evaluator/declaredPredicate.d.ts +93 -9
  37. package/dist/evaluator/declaredPredicate.js +118 -17
  38. package/dist/evaluator/fieldRules.d.ts +107 -4
  39. package/dist/evaluator/fieldRules.js +231 -35
  40. package/dist/evaluator/index.d.ts +2 -0
  41. package/dist/evaluator/index.js +2 -0
  42. package/dist/evaluator/listConditional.d.ts +125 -9
  43. package/dist/evaluator/listConditional.js +167 -26
  44. package/dist/evaluator/optionRules.d.ts +126 -0
  45. package/dist/evaluator/optionRules.js +141 -4
  46. package/dist/evaluator/predicateInput.d.ts +15 -4
  47. package/dist/evaluator/predicateInput.js +35 -2
  48. package/dist/evaluator/rowPredicateCanon.d.ts +56 -0
  49. package/dist/evaluator/rowPredicateCanon.js +158 -0
  50. package/dist/evaluator/subjectPermissions.d.ts +76 -0
  51. package/dist/evaluator/subjectPermissions.js +44 -0
  52. package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
  53. package/dist/evaluator/unevaluablePredicate.js +70 -0
  54. package/dist/index.d.ts +18 -0
  55. package/dist/index.js +97 -2
  56. package/dist/protocols/index.d.ts +0 -1
  57. package/dist/protocols/index.js +9 -1
  58. package/dist/registry/Registry.d.ts +397 -48
  59. package/dist/registry/Registry.js +263 -11
  60. package/dist/registry/WidgetRegistry.js +7 -3
  61. package/dist/registry/html-tier-intrinsics.d.ts +90 -0
  62. package/dist/registry/html-tier-intrinsics.js +110 -0
  63. package/dist/registry/public-blocks.d.ts +8 -0
  64. package/dist/registry/public-blocks.js +57 -0
  65. package/dist/theme/ThemeEngine.js +1 -1
  66. package/dist/theme/index.d.ts +1 -1
  67. package/dist/theme/index.js +1 -1
  68. package/dist/types/index.d.ts +92 -13
  69. package/dist/utils/chart-category-key.d.ts +82 -0
  70. package/dist/utils/chart-category-key.js +84 -0
  71. package/dist/utils/chart-measure-key.d.ts +64 -0
  72. package/dist/utils/chart-measure-key.js +65 -0
  73. package/dist/utils/chart-presentation.d.ts +188 -0
  74. package/dist/utils/chart-presentation.js +345 -0
  75. package/dist/utils/chart-series.d.ts +271 -31
  76. package/dist/utils/chart-series.js +478 -55
  77. package/dist/utils/column-identity.d.ts +43 -0
  78. package/dist/utils/column-identity.js +47 -1
  79. package/dist/utils/column-sortability.d.ts +157 -0
  80. package/dist/utils/column-sortability.js +217 -0
  81. package/dist/utils/dashboard-filters.d.ts +38 -6
  82. package/dist/utils/dashboard-filters.js +107 -61
  83. package/dist/utils/dataset-format.d.ts +211 -30
  84. package/dist/utils/dataset-format.js +407 -52
  85. package/dist/utils/date-display.d.ts +354 -0
  86. package/dist/utils/date-display.js +463 -0
  87. package/dist/utils/date-macros.js +7 -0
  88. package/dist/utils/debug.js +7 -0
  89. package/dist/utils/drill-down.d.ts +78 -0
  90. package/dist/utils/drill-down.js +93 -0
  91. package/dist/utils/emptiness.d.ts +96 -0
  92. package/dist/utils/emptiness.js +101 -0
  93. package/dist/utils/expand-fields.d.ts +171 -11
  94. package/dist/utils/expand-fields.js +171 -11
  95. package/dist/utils/extract-records.d.ts +65 -5
  96. package/dist/utils/extract-records.js +65 -8
  97. package/dist/utils/filter-converter.d.ts +152 -4
  98. package/dist/utils/filter-converter.js +1092 -22
  99. package/dist/utils/filter-tokens.d.ts +49 -5
  100. package/dist/utils/filter-tokens.js +105 -34
  101. package/dist/utils/freeze-schema.d.ts +38 -1
  102. package/dist/utils/freeze-schema.js +10 -0
  103. package/dist/utils/grouping-fields.d.ts +61 -0
  104. package/dist/utils/grouping-fields.js +84 -0
  105. package/dist/utils/humanize-label.d.ts +60 -0
  106. package/dist/utils/humanize-label.js +62 -0
  107. package/dist/utils/managedBy.d.ts +30 -5
  108. package/dist/utils/managedBy.js +5 -5
  109. package/dist/utils/native-date-value.d.ts +48 -0
  110. package/dist/utils/native-date-value.js +167 -0
  111. package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
  112. package/dist/utils/non-grid-row-ceiling.js +141 -0
  113. package/dist/utils/normalize-list-view.d.ts +131 -1
  114. package/dist/utils/normalize-list-view.js +248 -6
  115. package/dist/utils/number-display.d.ts +70 -17
  116. package/dist/utils/number-display.js +34 -12
  117. package/dist/utils/object-aggregate-query.d.ts +113 -0
  118. package/dist/utils/object-aggregate-query.js +50 -0
  119. package/dist/utils/parent-scope.d.ts +123 -0
  120. package/dist/utils/parent-scope.js +140 -0
  121. package/dist/utils/predicate-fields.d.ts +62 -4
  122. package/dist/utils/predicate-fields.js +169 -4
  123. package/dist/utils/record-color.d.ts +75 -0
  124. package/dist/utils/record-color.js +65 -0
  125. package/dist/utils/record-source.d.ts +298 -0
  126. package/dist/utils/record-source.js +331 -0
  127. package/dist/utils/record-title.d.ts +148 -8
  128. package/dist/utils/record-title.js +220 -26
  129. package/dist/utils/reference-keys.d.ts +21 -22
  130. package/dist/utils/reference-keys.js +532 -29
  131. package/dist/utils/retired-field-types.d.ts +90 -0
  132. package/dist/utils/retired-field-types.js +124 -0
  133. package/dist/utils/sort-query.d.ts +128 -26
  134. package/dist/utils/sort-query.js +240 -13
  135. package/dist/utils/text-comparand.d.ts +92 -0
  136. package/dist/utils/text-comparand.js +99 -0
  137. package/dist/utils/unmaterialized-fields.d.ts +52 -0
  138. package/dist/utils/unmaterialized-fields.js +57 -0
  139. package/dist/utils/without-denied-fields.d.ts +49 -0
  140. package/dist/utils/without-denied-fields.js +52 -0
  141. package/dist/validation/index.d.ts +9 -4
  142. package/dist/validation/index.js +9 -4
  143. package/dist/validation/schema-validator.d.ts +1 -1
  144. package/dist/validation/schema-validator.js +305 -82
  145. package/dist/validation/server-owned-value.d.ts +62 -0
  146. package/dist/validation/server-owned-value.js +99 -0
  147. package/dist/validation/validators/object-validation-engine.d.ts +1 -1
  148. package/dist/validation/validators/object-validation-engine.js +14 -5
  149. package/package.json +8 -9
  150. package/dist/__benchmarks__/core.bench.d.ts +0 -8
  151. package/dist/__benchmarks__/core.bench.js +0 -53
  152. package/dist/data-scope/DataScopeManager.d.ts +0 -127
  153. package/dist/data-scope/DataScopeManager.js +0 -229
  154. package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
  155. package/dist/protocols/ResponsiveProtocol.js +0 -158
  156. package/dist/validation/validation-engine.d.ts +0 -88
  157. package/dist/validation/validation-engine.js +0 -434
@@ -5,6 +5,86 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
+ /**
9
+ * chart-series — shape a semantic-layer `queryDataset` result into the
10
+ * `{ data, xAxisKey, series }` triple a chart renderer consumes (ADR-0021, #1759).
11
+ *
12
+ * Shared by `ObjectChart` (plugin-charts) and `DatasetWidget` (plugin-dashboard)
13
+ * so multi-dimension charts visualise identically across surfaces.
14
+ *
15
+ * Rules:
16
+ * - **2+ dimensions, single measure** → pivot the SECOND dimension into one
17
+ * series per distinct value (grouped/coloured bars). `xAxisKey` = first
18
+ * dimension; each output row is one first-dimension bucket with a column per
19
+ * second-dimension value holding the measure. This makes the second dimension
20
+ * visible instead of just repeating the x-axis label.
21
+ * - **otherwise** (single dimension, or multiple measures) → first dimension is
22
+ * the x-axis and each measure is its own series (long format passes through).
23
+ *
24
+ * BOTH branches map a NULL first-dimension VALUE to an explicit bucket label
25
+ * ({@link NULL_CATEGORY_LABEL}) so the group renders instead of vanishing —
26
+ * objectui#4466 for the single-dimension branch, objectui#4497 for the pivot.
27
+ * One doctrine, one predicate (`isNullCategory`), two call sites.
28
+ *
29
+ * The pivot's SECOND dimension gets the same answer as of objectui#4673, where
30
+ * it needed one more thing than an axis bucket does: a series bucket's key is a
31
+ * renderer `dataKey` AND a column in the emitted row, so it has to be
32
+ * collision-safe as well as drawable. See {@link pivotSeriesBuckets}.
33
+ *
34
+ * A bucket's IDENTITY is separate from that display label (objectui#4508). The
35
+ * label used to serve as its own key, which conflated two pairs of genuinely
36
+ * different groups; see {@link chartBucketId} for the identity, and
37
+ * {@link CHART_BUCKET_ID_KEY} for how it reaches a click handler.
38
+ *
39
+ * Key ABSENCE survives the pivot as of objectui#4507: an emitted bucket carries
40
+ * the x-axis key exactly when some row of it did. A first dimension that was
41
+ * grouped by but never PROJECTED therefore still reaches the renderer key-less,
42
+ * where `hasNoCategoryKey` (framework#4033) names it instead of drawing an axis
43
+ * with no marks. "Known to be empty" draws under the bucket label, "cannot
44
+ * know" refuses — one doctrine, and now BOTH branches state it.
45
+ */
46
+ import { pivotBucketId, pivotDimensionValue } from './dataset-pivot.js';
47
+ /**
48
+ * The closed vocabulary of server-side built-in default measures a result field
49
+ * can declare itself as (objectui#7258 / objectstack#14492) — the spec's own
50
+ * measure-aggregate enum spelling (`count_distinct`, not `countDistinct`), so
51
+ * there is exactly ONE spelling on the wire and this list never grows on its
52
+ * own: a value outside it is not "a new aggregate", it is an unrecognised one,
53
+ * and {@link resolveMeasureLabel} treats it as absent.
54
+ */
55
+ export const BUILTIN_AGGREGATES = ['count', 'sum', 'avg', 'min', 'max', 'count_distinct'];
56
+ /** Runtime membership test for {@link BUILTIN_AGGREGATES}, over an untrusted wire value. */
57
+ export function isBuiltinAggregate(value) {
58
+ return typeof value === 'string' && BUILTIN_AGGREGATES.includes(value);
59
+ }
60
+ /**
61
+ * The display label of ONE result field, for a legend / axis / tooltip.
62
+ *
63
+ * Resolution order (objectui#7258, maintainer ruling B of 2026-09-02):
64
+ * 1. the field declares itself a built-in default measure
65
+ * (`builtinAggregate` ∈ {@link BUILTIN_AGGREGATES}) AND the caller resolved
66
+ * a locale label for it → that label. This is the only path that can
67
+ * replace a wire `label`, and it is keyed by a STRUCTURAL discriminator —
68
+ * never by matching the label's text or the field's name, both of which
69
+ * the ruling rejected (a real author measure literally named `count`, or
70
+ * labelled `Count`, must keep its own label);
71
+ * 2. otherwise the wire `label` verbatim — the objectui#4106 contract for an
72
+ * author-declared measure (`'Tasks'` reaches the legend untouched);
73
+ * 3. otherwise the field's `name`.
74
+ *
75
+ * So a discriminator the caller has no label for (a provider-less host, or a
76
+ * value outside the closed vocabulary) costs nothing: the field renders exactly
77
+ * what it rendered before the discriminator existed.
78
+ */
79
+ export function resolveMeasureLabel(field, labels) {
80
+ const aggregate = field.builtinAggregate;
81
+ if (isBuiltinAggregate(aggregate)) {
82
+ const localized = labels?.[aggregate];
83
+ if (typeof localized === 'string' && localized !== '')
84
+ return localized;
85
+ }
86
+ return field.label ?? field.name;
87
+ }
8
88
  /**
9
89
  * The bucket a NULL/undefined category VALUE renders under, when the caller
10
90
  * supplies no localized label of its own (objectui#4466).
@@ -22,6 +102,64 @@
22
102
  * clicked bucket bar resolve back to its (raw null) dataset row.
23
103
  */
24
104
  export const NULL_CATEGORY_LABEL = '(None)';
105
+ /**
106
+ * The IDENTITY of the chart bucket a first-dimension raw value falls in —
107
+ * distinct from the string that bucket DISPLAYS (objectui#4508).
108
+ *
109
+ * Deliberately the SAME encoder the pivot TABLE already keys its buckets with
110
+ * (`pivotBucketId` over a `pivotDimensionValue`-normalized value), because the
111
+ * two surfaces answer the same question about the same dataset rows and were
112
+ * answering it differently: `buildPivot` gives a null dimension value a bucket
113
+ * id no string can spell, while the chart used the display label as its own
114
+ * key. That gap is the whole of objectui#4508 — see `dataset-pivot.ts` for why
115
+ * the encoding is JSON rather than a delimiter or a placeholder character.
116
+ *
117
+ * Two collisions follow from a label-as-key, and one encoder closes both:
118
+ * - `null` and `''` are different groups. `String(x ?? '')` made them one
119
+ * bucket, so two groups drew ONE bar and one of them lost its drill.
120
+ * `[null]` and `[""]` are different ids.
121
+ * - a stored value that literally spells the bucket label (`'(None)'`, or any
122
+ * localized `nullCategoryLabel` — `'(未指定)'` and the other nine packs) is
123
+ * not the null group. `["(None)"]` and `[null]` are different ids, so the
124
+ * click resolves to the rows its own bar was drawn from.
125
+ */
126
+ export function chartBucketId(rawValue) {
127
+ return pivotBucketId([pivotDimensionValue(rawValue)]);
128
+ }
129
+ /**
130
+ * The key an emitted chart row carries its {@link chartBucketId} under, when it
131
+ * carries one — the "alongside the display label" half of objectui#4508.
132
+ *
133
+ * **Written exactly when the display string does not name one bucket**, i.e.
134
+ * when two or more DISTINCT buckets render the same axis text. That is not a
135
+ * cost-saving heuristic, it is the complete condition: with the reader's
136
+ * matching made exact (see {@link findChartSeriesRow}), a display string that
137
+ * one bucket alone produces already identifies it, and the rows a chart is
138
+ * drawn from are also an authoring-visible surface (`data` on a `chart`
139
+ * schema) that renderer-internal metadata should not appear in for every
140
+ * ordinary chart. Rows that do not carry the category key at all never get one
141
+ * — they have no bucket, and that shape belongs to `hasNoCategoryKey`
142
+ * (framework#4033), not here.
143
+ *
144
+ * Read it with {@link chartRowBucketId} rather than the literal key.
145
+ */
146
+ export const CHART_BUCKET_ID_KEY = '__bucketId';
147
+ /**
148
+ * Read the bucket identity off a row a chart was drawn from, or `undefined`
149
+ * when the row carries none (an unambiguous bucket, or rows a caller built
150
+ * itself rather than through {@link buildChartSeries}).
151
+ *
152
+ * Takes `unknown` because its callers hold a click payload, not a typed row:
153
+ * recharts hands a pie/funnel click a SPREAD COPY of the data item, which is
154
+ * why the identity is an ordinary enumerable property and not a symbol or a
155
+ * non-enumerable one — either of those would silently not survive the copy.
156
+ */
157
+ export function chartRowBucketId(row) {
158
+ if (!row || typeof row !== 'object')
159
+ return undefined;
160
+ const id = row[CHART_BUCKET_ID_KEY];
161
+ return typeof id === 'string' && id !== '' ? id : undefined;
162
+ }
25
163
  /**
26
164
  * Does this row carry `key` with a NULL/undefined value — the shape that gets
27
165
  * the bucket label (objectui#4466 / objectui#4497)?
@@ -39,69 +177,279 @@ export const NULL_CATEGORY_LABEL = '(None)';
39
177
  * sentence and a false one.
40
178
  */
41
179
  function isNullCategory(row, key) {
42
- return (!!row &&
43
- typeof row === 'object' &&
44
- key in row &&
45
- row[key] == null);
180
+ return carriesKey(row, key) && row[key] == null;
181
+ }
182
+ /**
183
+ * Does this row carry `key` AT ALL — the precondition {@link isNullCategory}
184
+ * and every bucketing call site share.
185
+ *
186
+ * Stated once because the two halves of the doctrine hang off it and the
187
+ * difference is invisible at a glance: `row[key] == null` is TRUE for a row
188
+ * that simply has no such column, so reading the raw value alone silently
189
+ * relabels "this dimension was never projected" as "these records have no
190
+ * value" (framework#4033's signal, erased). Every place that decides whether a
191
+ * row has a bucket asks THIS, never the raw value.
192
+ */
193
+ function carriesKey(row, key) {
194
+ return !!row && typeof row === 'object' && key in row;
195
+ }
196
+ /**
197
+ * The axis text a bucket paints, from its ALREADY-RESOLVED display value — the
198
+ * string a chart library puts on the tick and hands back as the clicked
199
+ * category. Two buckets "look alike" exactly when these are equal: `1` and
200
+ * `'1'` included (which is why this stringifies rather than comparing raw
201
+ * values), and an absent display alongside `''` (both paint a blank tick).
202
+ *
203
+ * Takes the display value, never the raw one: in both branches a null category
204
+ * has ALREADY become the bucket label by the time ambiguity is asked about, and
205
+ * feeding the raw value here would ask the question about the wrong string.
206
+ */
207
+ function bucketDisplayKey(displayValue) {
208
+ return displayValue == null ? '' : String(displayValue);
209
+ }
210
+ /**
211
+ * Which display strings are produced by MORE THAN ONE distinct bucket — the
212
+ * complete set of cases where the axis text cannot name the bucket a user
213
+ * clicked, and therefore exactly the set that needs {@link CHART_BUCKET_ID_KEY}
214
+ * written (objectui#4508).
215
+ *
216
+ * Counted over DISTINCT ids, not over rows: two rows of the same group share
217
+ * one id and are one bucket, so an ordinary repeated category is not ambiguous
218
+ * and its rows stay untouched.
219
+ */
220
+ function ambiguousDisplays(buckets) {
221
+ const idsByDisplay = new Map();
222
+ for (const { id, display } of buckets) {
223
+ let ids = idsByDisplay.get(display);
224
+ if (!ids)
225
+ idsByDisplay.set(display, (ids = new Set()));
226
+ ids.add(id);
227
+ }
228
+ const ambiguous = new Set();
229
+ for (const [display, ids] of idsByDisplay)
230
+ if (ids.size > 1)
231
+ ambiguous.add(display);
232
+ return ambiguous;
46
233
  }
47
234
  /**
48
235
  * Map a null/undefined category VALUE to its bucket label, for the
49
- * single-dimension branch (objectui#4466).
236
+ * single-dimension branch (objectui#4466), and give a bucket whose label is
237
+ * shared with another bucket its own identity (objectui#4508).
50
238
  *
51
- * Returns the input array itself when nothing was null, and copies only the
239
+ * Returns the input array itself when neither applied, and copies only the
52
240
  * rows it rewrites, so the caller's rows are never mutated — dataset surfaces
53
241
  * drill through by index into the RAW rows, which must keep their null.
242
+ *
243
+ * A row that does not carry `key` at all is left ENTIRELY alone: it has no
244
+ * bucket to label and none to identify, and adding either would erase the
245
+ * signal `hasNoCategoryKey` (framework#4033) reads.
54
246
  */
55
247
  function bucketNullCategories(rows, key, label) {
248
+ // The value this row's bar will PAINT — the label when its category is null,
249
+ // its own value otherwise. Ambiguity is a question about that string.
250
+ const displayOf = (row) => isNullCategory(row, key) ? label : row[key];
251
+ const ambiguous = ambiguousDisplays(rows.filter((row) => carriesKey(row, key)).map((row) => ({
252
+ id: chartBucketId(row[key]),
253
+ display: bucketDisplayKey(displayOf(row)),
254
+ })));
56
255
  let changed = false;
57
256
  const next = rows.map((row) => {
58
- if (!isNullCategory(row, key))
257
+ if (!carriesKey(row, key))
258
+ return row;
259
+ const isNull = isNullCategory(row, key);
260
+ const shared = ambiguous.has(bucketDisplayKey(displayOf(row)));
261
+ if (!isNull && !shared)
59
262
  return row;
60
263
  changed = true;
61
- return { ...row, [key]: label };
264
+ return {
265
+ ...row,
266
+ ...(isNull ? { [key]: label } : {}),
267
+ ...(shared ? { [CHART_BUCKET_ID_KEY]: chartBucketId(row[key]) } : {}),
268
+ };
62
269
  });
63
270
  return changed ? next : rows;
64
271
  }
272
+ /**
273
+ * The pivot's series buckets, in first-seen order — objectui#4673.
274
+ *
275
+ * The second dimension used to be bucketed by `String(row[groupKey] ?? '')`
276
+ * behind a `gId !== ''` gate, which is the PRE-objectui#4466 answer one
277
+ * dimension over: a null (or empty-string) group never joined the series list,
278
+ * while the line below the gate still wrote its measure into the bucket. The
279
+ * number was in the emitted row and bound to no mark — the chart understated
280
+ * its own data without saying so, which is #4466's harm verbatim.
281
+ *
282
+ * A series bucket needs one thing an axis bucket does not, and that is the
283
+ * whole reason this is a function rather than two more lines inline: its key is
284
+ * a **row column and a renderer `dataKey`**, so it must be UNIQUE within the
285
+ * emitted row, where an axis bucket's key is a private map key. Hence the
286
+ * assignment below, which is injective by construction:
287
+ *
288
+ * - a bucket keys its column by its own LABEL — so an ordinary pivot's rows
289
+ * and series are byte-identical to what they have always been, and the
290
+ * legend, the tooltip and the drill title read the group's own text;
291
+ * - unless that label cannot NAME it: shared with another bucket (the null
292
+ * bucket beside a stored value spelling its label — objectui#4508's second
293
+ * collision, on the series axis), reserved by the row itself (`xKey`, whose
294
+ * column IS the axis value, and {@link CHART_BUCKET_ID_KEY}), or equal to
295
+ * some bucket's identity;
296
+ * - in which case it keys by its own IDENTITY, which no other bucket has.
297
+ *
298
+ * The third exclusion is what makes the first two safe rather than merely
299
+ * likely: identities are pairwise distinct, and no surviving label is one of
300
+ * them, so the two key spaces cannot meet. That is the card's "collision-safe"
301
+ * requirement discharged, not approximated — a record whose stored group value
302
+ * literally spells `[null]` is a real value with a real bar, and it keeps it.
303
+ *
304
+ * The empty-string group keys by its own label, `''`, exactly as it paints a
305
+ * blank legend entry (and a blank tick on the x-axis — objectui#4508 ruled `''`
306
+ * and `null` two different groups, and this is that ruling on the series axis).
307
+ * Measured, not assumed: recharts binds `dataKey=""` through the same
308
+ * `getValueByDataKey` path as any other key and draws the bar at its true
309
+ * height. That measurement is pinned at the DOM in plugin-charts.
310
+ *
311
+ * A row that does not carry `groupKey` at all gets NO bucket and contributes no
312
+ * column — the other half of the doctrine, unchanged: "known to be empty" draws
313
+ * and "cannot know" refuses (framework#4033). Inventing a group for it would
314
+ * merge unprojected rows into the empty-string group and call it data.
315
+ */
316
+ function pivotSeriesBuckets(rows, groupKey, nullLabel, xKey) {
317
+ const byId = new Map();
318
+ for (const row of rows) {
319
+ if (!carriesKey(row, groupKey))
320
+ continue;
321
+ const id = chartBucketId(row[groupKey]);
322
+ if (byId.has(id))
323
+ continue;
324
+ byId.set(id, { id, label: isNullCategory(row, groupKey) ? nullLabel : String(row[groupKey]) });
325
+ }
326
+ const buckets = Array.from(byId.values());
327
+ const shared = ambiguousDisplays(buckets.map(({ id, label }) => ({ id, display: label })));
328
+ const reserved = new Set([xKey, CHART_BUCKET_ID_KEY]);
329
+ const identities = new Set(buckets.map(({ id }) => id));
330
+ return buckets.map(({ id, label }) => ({
331
+ id,
332
+ label,
333
+ dataKey: shared.has(label) || reserved.has(label) || identities.has(label) ? id : label,
334
+ }));
335
+ }
65
336
  export function buildChartSeries(rows, dimensions, values, fields, options) {
66
337
  const dims = (dimensions ?? []).filter(Boolean);
67
338
  const vals = (values ?? []).filter(Boolean);
68
339
  const safeRows = Array.isArray(rows) ? rows : [];
69
- const labelOf = (name) => (fields ?? []).find((f) => f.name === name)?.label ?? name;
340
+ // A measure's series label: the locale label when the field declares itself
341
+ // a built-in default measure and the caller resolved one, else the wire
342
+ // `label` verbatim (objectui#4106), else the name — see `resolveMeasureLabel`.
343
+ const labelOf = (name) => {
344
+ const field = (fields ?? []).find((f) => f.name === name);
345
+ return field ? resolveMeasureLabel(field, options?.builtinAggregateLabels) : name;
346
+ };
70
347
  // Multi-dimension, single-measure → pivot the second dimension into series.
71
348
  if (dims.length >= 2 && vals.length === 1) {
72
349
  const xKey = dims[0];
73
350
  const groupKey = dims[1];
74
351
  const measure = vals[0];
75
352
  const nullLabel = options?.nullCategoryLabel ?? NULL_CATEGORY_LABEL;
76
- const seriesKeys = [];
353
+ // Every second-dimension group, INCLUDING the null and empty-string ones
354
+ // the `gId !== ''` gate used to drop on the floor (objectui#4673), each
355
+ // already carrying the column its measure lands in.
356
+ const groups = pivotSeriesBuckets(safeRows, groupKey, nullLabel, xKey);
357
+ const columnById = new Map(groups.map(({ id, dataKey }) => [id, dataKey]));
77
358
  const byX = new Map();
78
359
  for (const row of safeRows) {
79
360
  const xRaw = row[xKey];
80
- const xId = String(xRaw ?? '');
81
- // The bucket KEY is untouched — `String(xRaw ?? '')` still decides WHICH
82
- // rows share a bar, so every existing grouping is byte-identical. What
83
- // changes is the DISPLAY value the bucket carries: a null first-dimension
84
- // value used to be written into the emitted row raw, so the bar reached
85
- // recharts with a null category and drew no mark — the same defect
86
- // objectui#4466 fixed one branch below, and the reason key and row value
87
- // were two different things here (objectui#4497).
361
+ // The bucket KEY is the bucket's IDENTITY, not its display string
362
+ // (objectui#4508). It used to be `String(xRaw ?? '')`, which merged two
363
+ // groups that are not the same group: a stored `null` and a stored `''`
364
+ // encoded identically, so they drew ONE bar carrying both groups' series
365
+ // and the segment belonging to the later one resolved to no row at all.
366
+ // `chartBucketId` keeps them apart, and every value that can spell the
367
+ // bucket LABEL apart from the null bucket with it.
368
+ //
369
+ // The DISPLAY value stays what objectui#4497 made it: a null
370
+ // first-dimension value renders under the bucket label rather than
371
+ // reaching the renderer raw (which drew no mark — the objectui#4466
372
+ // defect, one branch below). Key and display value are two different
373
+ // things here, and now they are two different KINDS of thing.
374
+ //
375
+ // The bucket takes its label from the first row that CARRIES the key,
376
+ // exactly as it took the CREATING row's raw value before — the same row
377
+ // in every bucket a projected dimension produces, because a row whose
378
+ // `xKey` is absent reads `xRaw === undefined` and therefore lands in the
379
+ // `[null]` bucket and nowhere else.
380
+ //
381
+ // A bucket carries `xKey` exactly when SOME row of it did, and that is
382
+ // objectui#4507: the write used to be unconditional, so a first dimension
383
+ // GROUPED BY BUT NEVER PROJECTED (framework#4033 — no row carries the key
384
+ // at all) reached the renderer as `{ [xKey]: undefined }`, `key in row`
385
+ // answered true, and `hasNoCategoryKey` could not fire for a pivoted
386
+ // chart. The defence was dead exactly where the defect it guards against
387
+ // lands: every row collapsed into one bucket painting a blank tick, which
388
+ // is the silent shape the placeholder exists to replace. The single-
389
+ // dimension branch never had the problem — it passes rows through, so
390
+ // key-absent rows stay key-absent — and this is that same answer here.
88
391
  //
89
- // The bucket takes its label from the row that CREATED it, exactly as it
90
- // took its raw value before.
91
- if (!byX.has(xId)) {
92
- byX.set(xId, { [xKey]: isNullCategory(row, xKey) ? nullLabel : xRaw });
392
+ // MEASURED end to end before it was written (objectui#4507): the strategy
393
+ // that maps engine rows to result rows writes a dimension key only when
394
+ // the engine actually returned that column (`if (shortName in row)`), the
395
+ // native-SQL strategy returns driver rows that carry only the columns the
396
+ // statement selected, and JSON cannot transport `undefined` in the first
397
+ // place — so an unprojected dimension arrives as an ABSENT key, while an
398
+ // explicit `null` means the column WAS projected and its value is null.
399
+ // Those are the two shapes, they are cleanly separated at the source, and
400
+ // this branch now keeps them apart instead of merging them.
401
+ //
402
+ // Why the bug was invisible to every pin that "saw" the emitted row: both
403
+ // `JSON.stringify` and `toEqual` DROP an undefined-valued key, so the
404
+ // pivot's output read as key-absent everywhere except in the one test
405
+ // that decides the placeholder — `key in row`.
406
+ //
407
+ // The upgrade in the second clause is not cosmetic. `chartBucketId`
408
+ // encodes an absent value and a stored `null` identically (`[null]`, by
409
+ // ADR-0021's normalization), so ONE bucket can collect both key-absent
410
+ // rows and rows whose category is genuinely null. If any of its rows
411
+ // carried the key, the bucket is "known to be empty" and draws under the
412
+ // label; refusing there would tell the author their query never projected
413
+ // a dimension that it did project — a false sentence, and the mirror of
414
+ // the false sentence objectui#4497 refused to print by NOT relabelling a
415
+ // key-absent row `(None)`. Both halves of that doctrine are now live in
416
+ // this branch: key absent everywhere → the framework#4033 placeholder;
417
+ // key present with a null value → the bucket label.
418
+ const xId = chartBucketId(xRaw);
419
+ let bucket = byX.get(xId);
420
+ if (!bucket)
421
+ byX.set(xId, (bucket = {}));
422
+ if (carriesKey(row, xKey) && !carriesKey(bucket, xKey)) {
423
+ bucket[xKey] = isNullCategory(row, xKey) ? nullLabel : xRaw;
93
424
  }
94
- const gId = String(row[groupKey] ?? '');
95
- if (gId !== '' && !seriesKeys.includes(gId))
96
- seriesKeys.push(gId);
97
- byX.get(xId)[gId] = row[measure];
425
+ // The group's own column, or nothing at all when the row carries no
426
+ // second dimension to be grouped BY. Nothing at all is the point: the
427
+ // old code wrote such a row's measure under `''` — a column no series
428
+ // bound to, and after objectui#4673 the empty-string GROUP's column,
429
+ // which would hand an unprojected row's number to a real group's bar.
430
+ const column = carriesKey(row, groupKey)
431
+ ? columnById.get(chartBucketId(row[groupKey]))
432
+ : undefined;
433
+ if (column !== undefined)
434
+ bucket[column] = row[measure];
98
435
  }
436
+ // Two DISTINCT buckets can still render the same axis text — the null
437
+ // bucket beside a stored value that literally spells its label. Those, and
438
+ // only those, carry their identity to the click handler.
439
+ const ambiguous = ambiguousDisplays(Array.from(byX.entries()).map(([id, bucket]) => ({
440
+ id,
441
+ display: bucketDisplayKey(bucket[xKey]),
442
+ })));
99
443
  return {
100
- data: Array.from(byX.values()),
444
+ data: Array.from(byX.entries()).map(([id, bucket]) => ambiguous.has(bucketDisplayKey(bucket[xKey]))
445
+ ? { ...bucket, [CHART_BUCKET_ID_KEY]: id }
446
+ : bucket),
101
447
  xAxisKey: xKey,
102
448
  // Series labels are the second-dimension values themselves (already
103
- // server-resolved to display labels by queryDataset).
104
- series: seriesKeys.map((k) => ({ dataKey: k, label: k })),
449
+ // server-resolved to display labels by queryDataset), except the null
450
+ // group, which says so with the bucket label the renderer supplied. The
451
+ // key is a separate question from the text — see `pivotSeriesBuckets`.
452
+ series: groups.map(({ dataKey, label }) => ({ dataKey, label })),
105
453
  };
106
454
  }
107
455
  // Default: first dimension on the x-axis, one series per measure.
@@ -184,16 +532,37 @@ export function relabelDimensions(rows, labelMaps) {
184
532
  * BOTH the x-axis dimension (`category`) and the series dimension (`seriesKey`).
185
533
  * - **otherwise** → match the x-axis (first) dimension only.
186
534
  *
187
- * Comparison is string-wise on the rows' display values (which is what the chart
188
- * surfaces as `category` / series key). Returns `-1` when nothing matches.
535
+ * The x-axis comparison is string-wise on the rows' display values (which is
536
+ * what the chart surfaces as `category`), unless the click carried a bucket
537
+ * IDENTITY — {@link ChartDrillLookupOptions.bucketId} — in which case that is
538
+ * authoritative and the display string is not consulted at all. Returns `-1`
539
+ * when nothing matches.
540
+ *
541
+ * The SERIES comparison is not string-wise at all as of objectui#4673: a series
542
+ * key is a `dataKey` {@link buildChartSeries} assigned, so it is resolved back
543
+ * through the same {@link pivotSeriesBuckets} assignment (same rows, same
544
+ * `nullCategoryLabel`) to the group IDENTITY it names, and rows are matched on
545
+ * that. A null-valued second dimension had no series at all before, so its
546
+ * segment could not be clicked; now it can, and `String(r[gDim] ?? '')` would
547
+ * have answered that click with an empty-string group's rows — the same
548
+ * conflation objectui#4508 removed on the x-axis.
189
549
  *
190
550
  * The NULL-category bucket {@link buildChartSeries} renders (objectui#4466) is
191
551
  * matched back to its row here, and it has to be: the caller passes the rows it
192
552
  * charted FROM (still carrying the raw null) while the click event carries the
193
553
  * bucket LABEL, so without this the one bar the fix made visible would resolve
194
554
  * to `-1` and its drill-through would silently no-op. Pass the same
195
- * `nullCategoryLabel` both helpers were given. The pre-existing `''` spelling
196
- * of "no group value" still matches too — `computeDrillFilter` writes that one.
555
+ * `nullCategoryLabel` both helpers were given.
556
+ *
557
+ * **A null category reads as its bucket LABEL and nothing else** (objectui#4508).
558
+ * It used to read as the label OR the empty string, on the stated grounds that
559
+ * `''` is the drill layer's own spelling of "no group value" — but no producer
560
+ * of this function's `category` argument writes that spelling (the one
561
+ * production caller feeds it a chart click's category, and `computeDrillFilter`
562
+ * is downstream of the drill, not upstream of this lookup), while `''` IS what
563
+ * a genuine empty-string group paints. So the tolerance cost that group its own
564
+ * drill and gave it a null group's records instead — a WRONG click, not a dead
565
+ * one. `''` now means the empty-string group, which is what it says.
197
566
  *
198
567
  * **`xOf` covers BOTH arms, which is what let objectui#4497 bucket the pivot
199
568
  * branch without touching this function.** Worth stating because the reverse
@@ -216,15 +585,35 @@ export function findChartSeriesRow(rows, dimensions, values, category, seriesKey
216
585
  return -1;
217
586
  const c = String(category ?? '');
218
587
  const nullLabel = options?.nullCategoryLabel ?? NULL_CATEGORY_LABEL;
219
- // A null x reads as BOTH its rendered bucket label and the legacy '' — the
220
- // two spellings of the same fact, neither of which a stored value can be.
221
- const xOf = (r) => r[xDim] == null ? (c === nullLabel ? nullLabel : '') : String(r[xDim]);
588
+ const bucketId = options?.bucketId;
589
+ // Identity wins outright when the click carried one: it is the ONE thing that
590
+ // survives two buckets painting the same axis text, which is the case the
591
+ // display string provably cannot answer.
592
+ const matchesX = typeof bucketId === 'string' && bucketId !== ''
593
+ ? (r) => chartBucketId(r[xDim]) === bucketId
594
+ // Otherwise the bucket's own display string, exactly: a null category
595
+ // reads as its rendered label, every other value as itself.
596
+ : (r) => (r[xDim] == null ? nullLabel : String(r[xDim])) === c;
222
597
  if (dims.length >= 2 && vals.length === 1) {
223
598
  const gDim = dims[1];
224
- const s = String(seriesKey ?? '');
225
- return safeRows.findIndex((r) => xOf(r) === c && String(r[gDim] ?? '') === s);
599
+ // The clicked series key is a `dataKey` `buildChartSeries` ASSIGNED, so it
600
+ // is resolved the way it was assigned — through the same buckets, rebuilt
601
+ // from the same raw rows (`DatasetWidget.handleChartDrill`, the one
602
+ // production caller, hands both helpers the same array and the same
603
+ // `nullCategoryLabel`). Matching then compares IDENTITIES, which is
604
+ // objectui#4508's answer extended to the series axis: `String(r[gDim] ?? '')`
605
+ // was display-string matching, and it spelled a null group and an
606
+ // empty-string group alike — the tolerance #4508 removed one dimension over.
607
+ const groups = pivotSeriesBuckets(safeRows, gDim, nullLabel, xDim);
608
+ const gId = groups.find(({ dataKey }) => dataKey === String(seriesKey ?? ''))?.id;
609
+ // No bucket answers to that key: these rows have no such group, so no row
610
+ // can belong to it. `-1` is the documented "nothing matched" — widening to
611
+ // a string comparison here would put back exactly what #4508 measured out.
612
+ if (gId === undefined)
613
+ return -1;
614
+ return safeRows.findIndex((r) => matchesX(r) && carriesKey(r, gDim) && chartBucketId(r[gDim]) === gId);
226
615
  }
227
- return safeRows.findIndex((r) => xOf(r) === c);
616
+ return safeRows.findIndex((r) => matchesX(r));
228
617
  }
229
618
  /**
230
619
  * Build a per-category colour map from a select/lookup field's `options`.
@@ -423,10 +812,54 @@ function fieldDefsOf(schema) {
423
812
  * The object a relationship field points at, or `undefined` when the field is
424
813
  * not a relationship (or names no target).
425
814
  *
426
- * The target lives under `reference` on framework-served field defs; older /
427
- * spec shapes spell it `reference_to` / `referenceTo` / `reference_to_object`,
428
- * and any of them may carry a bare name, a one-element array, or `{ object }`.
429
- * Same canonicalization as the dataset designer's `resolveReferenceTo`.
815
+ * The target lives under `reference` — the ONLY spelling read here, and the same
816
+ * canonicalization as the dataset designer's `resolveReferenceTo`, which is
817
+ * narrowed to match in the same pass (objectui#6528). As of objectui#6648 the
818
+ * CARRIER matches too: a bare string and nothing else. The two helpers are one
819
+ * doctrine in two files and must never drift — a divergence recreates the
820
+ * defect one file over.
821
+ *
822
+ * The three legacy spellings this dropped (`reference_to` / `referenceTo` /
823
+ * `reference_to_object`) were censused against every producer, with `reference`
824
+ * itself as the positive control (445 of 565 lookup / master_detail defs in the
825
+ * framework tree; ACCEPTED by `ObjectSchema.safeParse` on spec 17.2.0, which
826
+ * REFUSES all three others BY NAME). No producer emits any of them onto an
827
+ * object metadata document: `reference_to` was then live only on ObjectUI's own
828
+ * view/field schema (a different contract, translated INTO from `reference`;
829
+ * objectui#11070 round 4 moved that contract to `reference` as well),
830
+ * `referenceTo`'s two producers were retired by objectui#6041, and
831
+ * `reference_to_object` occurs nowhere in either tree outside this chain and the
832
+ * test that called it.
833
+ *
834
+ * ⚠ Unlike `resolveReferenceTo`, this helper reads the `GET /meta/object/:name`
835
+ * document DIRECTLY — no `readFields` door strips retired keys on this path. So
836
+ * the narrowing is load-bearing rather than cosmetic: a stored pre-objectui#6041
837
+ * row spelling the target `referenceTo` now resolves to `undefined` here, the
838
+ * walk yields no entry, and the caller keeps the raw value (best-effort by
839
+ * construction — see {@link resolveDimensionFieldMeta}). That is the intended
840
+ * outcome: per AGENTS.md #0.1 such a row is a PRODUCER-side defect, and a
841
+ * lenient consumer here is exactly where it would have stayed hidden. Note
842
+ * `reference` was already HEAD of the old chain, so any doc carrying both is
843
+ * unaffected.
844
+ *
845
+ * The array and `{ object }` CARRIERS this used to accept were censused on the
846
+ * same standard and removed with the same reasoning (objectui#6648).
847
+ * `FieldSchema.reference` is a plain `z.string()`; `ObjectSchema.safeParse`
848
+ * (spec 17.2.0) ACCEPTS `reference: 'crm_account'` and REFUSES both
849
+ * `['crm_account']` and `{ object: 'crm_account' }` as `invalid_type`. A
850
+ * structure-walking, key-position-aware census of both trees found 587
851
+ * bare-string carriers at the field-def key position and ZERO array or
852
+ * `{ object }` carriers from any producer — the only ones in either tree were
853
+ * the green pins asserting this very tolerance. See `resolveReferenceTo` for
854
+ * the full census, including why a multi-target lookup is NOT what the array
855
+ * branch was serving: it returned `raw[0]` and discarded the rest, and
856
+ * polymorphic lookup remains an undeclared, open spec gap.
857
+ *
858
+ * This path matters MORE than the designer's for the same reason the spelling
859
+ * narrowing did: it reads the metadata document directly, with no `readFields`
860
+ * door in between, so a non-string carrier now degrades visibly (the walk
861
+ * yields no entry and the caller keeps the raw value) instead of being absorbed
862
+ * here.
430
863
  *
431
864
  * The **type gate is deliberate**: only a declared relationship is walked, so a
432
865
  * path segment naming a plain field can never be turned into an object name and
@@ -439,17 +872,8 @@ export function resolveRelationshipTarget(fieldDef) {
439
872
  const type = typeof def.type === 'string' ? def.type.toLowerCase() : '';
440
873
  if (!RELATIONSHIP_FIELD_TYPES.has(type))
441
874
  return undefined;
442
- const raw = def.reference ?? def.reference_to ?? def.referenceTo ?? def.reference_to_object;
443
- if (typeof raw === 'string' && raw)
444
- return raw;
445
- if (Array.isArray(raw) && typeof raw[0] === 'string' && raw[0])
446
- return raw[0];
447
- if (raw && typeof raw === 'object') {
448
- const obj = raw.object;
449
- if (typeof obj === 'string' && obj)
450
- return obj;
451
- }
452
- return undefined;
875
+ const raw = def.reference;
876
+ return typeof raw === 'string' && raw ? raw : undefined;
453
877
  }
454
878
  /**
455
879
  * Resolve each dataset dimension's underlying `field` to that field's select
@@ -541,7 +965,6 @@ export async function resolveDimensionFieldMeta(baseSchema, fieldPaths, loadObje
541
965
  walked = false;
542
966
  break;
543
967
  }
544
- // eslint-disable-next-line no-await-in-loop
545
968
  schema = await load(target);
546
969
  if (!schema) {
547
970
  walked = false;