@object-ui/core 17.5.0 → 17.6.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 (70) hide show
  1. package/CHANGELOG.md +794 -0
  2. package/README.md +36 -22
  3. package/dist/actions/ActionEngine.d.ts +1 -1
  4. package/dist/actions/ActionEngine.js +1 -2
  5. package/dist/actions/ActionRunner.d.ts +82 -10
  6. package/dist/actions/ActionRunner.js +128 -22
  7. package/dist/actions/actionKeys.d.ts +68 -8
  8. package/dist/actions/actionKeys.js +81 -9
  9. package/dist/actions/actionResponse.js +1 -1
  10. package/dist/actions/index.d.ts +1 -0
  11. package/dist/actions/index.js +1 -0
  12. package/dist/actions/recordIdParam.d.ts +77 -0
  13. package/dist/actions/recordIdParam.js +37 -0
  14. package/dist/actions/serverActionHandler.d.ts +1 -1
  15. package/dist/actions/serverActionHandler.js +1 -1
  16. package/dist/evaluator/ExpressionEvaluator.d.ts +42 -1
  17. package/dist/evaluator/ExpressionEvaluator.js +42 -1
  18. package/dist/evaluator/SafeExpressionParser.js +0 -4
  19. package/dist/evaluator/declaredPredicate.js +1 -1
  20. package/dist/evaluator/fieldRules.d.ts +19 -0
  21. package/dist/evaluator/fieldRules.js +30 -6
  22. package/dist/evaluator/index.d.ts +1 -0
  23. package/dist/evaluator/index.js +1 -0
  24. package/dist/evaluator/listConditional.d.ts +49 -2
  25. package/dist/evaluator/listConditional.js +70 -6
  26. package/dist/evaluator/optionRules.d.ts +55 -0
  27. package/dist/evaluator/optionRules.js +60 -0
  28. package/dist/evaluator/rowPredicateCanon.d.ts +67 -0
  29. package/dist/evaluator/rowPredicateCanon.js +208 -0
  30. package/dist/index.d.ts +4 -0
  31. package/dist/index.js +17 -0
  32. package/dist/registry/Registry.d.ts +41 -19
  33. package/dist/theme/index.d.ts +1 -1
  34. package/dist/theme/index.js +1 -1
  35. package/dist/types/index.d.ts +92 -13
  36. package/dist/utils/chart-presentation.d.ts +205 -0
  37. package/dist/utils/chart-presentation.js +280 -0
  38. package/dist/utils/chart-series.d.ts +140 -26
  39. package/dist/utils/chart-series.js +380 -39
  40. package/dist/utils/column-identity.d.ts +43 -0
  41. package/dist/utils/column-identity.js +47 -1
  42. package/dist/utils/dataset-format.js +18 -6
  43. package/dist/utils/debug.js +7 -0
  44. package/dist/utils/expand-fields.d.ts +97 -7
  45. package/dist/utils/expand-fields.js +97 -7
  46. package/dist/utils/filter-tokens.js +0 -1
  47. package/dist/utils/freeze-schema.d.ts +38 -1
  48. package/dist/utils/freeze-schema.js +10 -0
  49. package/dist/utils/humanize-label.d.ts +60 -0
  50. package/dist/utils/humanize-label.js +62 -0
  51. package/dist/utils/managedBy.d.ts +27 -2
  52. package/dist/utils/normalize-list-view.d.ts +10 -0
  53. package/dist/utils/normalize-list-view.js +63 -1
  54. package/dist/utils/number-display.d.ts +7 -3
  55. package/dist/utils/predicate-fields.d.ts +29 -4
  56. package/dist/utils/predicate-fields.js +44 -4
  57. package/dist/utils/retired-field-types.d.ts +90 -0
  58. package/dist/utils/retired-field-types.js +124 -0
  59. package/dist/utils/sort-query.d.ts +12 -10
  60. package/dist/utils/unmaterialized-fields.d.ts +52 -0
  61. package/dist/utils/unmaterialized-fields.js +57 -0
  62. package/dist/validation/index.d.ts +1 -0
  63. package/dist/validation/index.js +1 -0
  64. package/dist/validation/schema-validator.js +105 -0
  65. package/dist/validation/server-owned-value.d.ts +62 -0
  66. package/dist/validation/server-owned-value.js +99 -0
  67. package/dist/validation/validators/object-validation-engine.js +0 -2
  68. package/package.json +4 -4
  69. package/dist/__benchmarks__/core.bench.d.ts +0 -8
  70. package/dist/__benchmarks__/core.bench.js +0 -53
@@ -36,6 +36,7 @@
36
36
  import { evalFieldPredicate } from './fieldRules.js';
37
37
  import { ExpressionEvaluator } from './ExpressionEvaluator.js';
38
38
  import { toPredicateRecord } from '../utils/predicate-record.js';
39
+ import { warnNonCanonicalRowSpelling } from './rowPredicateCanon.js';
39
40
  /**
40
41
  * Syntax that only the legacy JS-dialect evaluator understands and that is NOT
41
42
  * valid CEL: `${…}` templates, strict (in)equality, JS optional-chaining
@@ -62,7 +63,14 @@ function warnLegacyDialect(source) {
62
63
  }
63
64
  const warnedError = new Set();
64
65
  /**
65
- * One-time fail-closed report for a predicate that could not be evaluated.
66
+ * One-time report for a predicate that could not be evaluated.
67
+ *
68
+ * The message names the caller's `fallback` as "its safe default" without
69
+ * asserting a direction, because the direction is the caller's: row surfaces
70
+ * fail CLOSED (a faulting `visible` hides the action), while a param-collection
71
+ * dialog fails OPEN (objectui#4640 — a silently hidden required param is the
72
+ * undiagnosable failure). Both are loud; only silence was ever ruled out
73
+ * (2026-08-06 on objectui#4051 / objectstack#5149).
66
74
  *
67
75
  * `reason` is the engine's own description of the failure (`"[runtime] No such
68
76
  * key: owner_id"`) — the single most useful line for the author, and precisely
@@ -129,11 +137,34 @@ function evalCel(pred, record, scope) {
129
137
  * Evaluate a single boolean predicate against a row record on the canonical CEL
130
138
  * engine (with a legacy-dialect fallback — see the module note). The row's
131
139
  * fields are bound three ways so every authoring convention resolves:
132
- * `record.status` (spec/canonical), bare `status` (row-action shorthand), and
133
- * `data.status` (legacy). The optional `scope` (host predicate scope) is bound
140
+ * `record.status`, bare `status` (row-action shorthand), and `data.status`.
141
+ *
142
+ * ⚠️ Those three are NOT peers, and this doc comment used to read as though
143
+ * they were. **The canon is `record.*`** (maintainer ruling 2026-08-20 on
144
+ * objectui#5330, option B); the other two are client tolerances in a
145
+ * deprecation window, kept because stored metadata carries them and warned
146
+ * about — from the CEL path below — by `warnNonCanonicalRowSpelling`. The
147
+ * server accepts `record.*` and NOTHING else: measured on
148
+ * `@objectstack/formula@17.1.0`, `buildScope({ record })` mounts exactly
149
+ * `['record']`, so a bare field faults `Unknown variable: status` there and a
150
+ * `data.*` predicate faults `Unknown variable: data`. See
151
+ * {@link ./rowPredicateCanon.ts} for the full measurement, for why `data.*` is
152
+ * the dangerous one (it is silently ACCEPTED by the server's authoring oracle
153
+ * and still binds nothing at runtime), and for why the deprecation is scoped to
154
+ * this runtime layer rather than declared platform-wide (`data` is the
155
+ * canonical root of a metadata-editing form — ADR-0089 D3).
156
+ *
157
+ * ⛔ No spelling is removed from the binding, and none may be before a
158
+ * stored-metadata survey sizes the window — that is part of the same ruling.
159
+ *
160
+ * The optional `scope` (host predicate scope) is bound
134
161
  * alongside so `features.*` / `user.*` predicates keep working — but the row is
135
162
  * the subject: `record` and `data` always name THIS row, on both dialect paths,
136
163
  * even when the host scope carries keys of those names (objectui#3796).
164
+ *
165
+ * A caller with no row at all passes {@link RowPredicateOptions.rowless}, and
166
+ * then nothing is bound over the scope — see that option for why an absent row
167
+ * must not be spelled as an empty one.
137
168
  */
138
169
  export function evalRowPredicate(pred, row, opts = {}) {
139
170
  const fallback = opts.fallback ?? false;
@@ -145,7 +176,10 @@ export function evalRowPredicate(pred, row, opts = {}) {
145
176
  // Relations collapse to the foreign key the server stores, so `record.owner`
146
177
  // means one thing whether or not this surface expanded the column
147
178
  // (objectui#3501). Returned by reference when there is nothing to collapse.
148
- const rowObj = toPredicateRecord(row && typeof row === 'object' ? row : {}, opts.fields);
179
+ // A `rowless` caller has no row to collapse, so `opts.fields` is moot there.
180
+ const rowObj = opts.rowless
181
+ ? {}
182
+ : toPredicateRecord(row && typeof row === 'object' ? row : {}, opts.fields);
149
183
  // Bare fields + `data.*` + `record.*` + the host scope, all top-level.
150
184
  //
151
185
  // `data` AND `record` are pinned AFTER the spread, so a host scope that
@@ -158,7 +192,28 @@ export function evalRowPredicate(pred, row, opts = {}) {
158
192
  // string happens to contain `===`/`${…}`, which no author is choosing
159
193
  // deliberately (objectui#3796). Pinning here fixes both paths at the merge,
160
194
  // independently of either engine's precedence.
161
- const scope = { ...(opts.scope ?? {}), ...rowObj, data: rowObj, record: rowObj };
195
+ //
196
+ // …UNLESS the caller has no row (`rowless`), in which case there is nothing
197
+ // to pin and the host scope keeps its own `record` / `data` — see the option.
198
+ const scope = opts.rowless
199
+ ? { ...(opts.scope ?? {}) }
200
+ : { ...(opts.scope ?? {}), ...rowObj, data: rowObj, record: rowObj };
201
+ // The predicate TEXT for diagnostics: a bare string is itself, an envelope is
202
+ // its `source`. Reported separately from `source` above, which is `undefined`
203
+ // for an envelope *by design* (it drives the dialect routing, and an envelope
204
+ // is never routed to the legacy engine). Reusing it for the warning is what
205
+ // made every faulting envelope report as the literal `"(expression)"` — no
206
+ // predicate text for the author, and, because the one-time key is
207
+ // (label, source), the FIRST faulting envelope on a given label silenced every
208
+ // other one under it (objectui#4640: measured on `evalRowPredicate` — a second
209
+ // envelope with a different, also-broken source warned zero times). The
210
+ // envelope is not an exotic spelling: `@objectstack/spec`'s
211
+ // `ExpressionInputSchema` normalizes every authored predicate into one, so it
212
+ // is the likeliest shape in served metadata and was the least diagnosable.
213
+ const predicateText = source ??
214
+ (pred && typeof pred === 'object' && typeof pred.source === 'string'
215
+ ? pred.source
216
+ : '(expression)');
162
217
  // Legacy-dialect *strings* route to the legacy engine (back-compat). The
163
218
  // `{ dialect: 'cel', source }` envelope is never routed here — it is CEL.
164
219
  if (source !== undefined && isLegacyDialectSource(source)) {
@@ -176,6 +231,15 @@ export function evalRowPredicate(pred, row, opts = {}) {
176
231
  return fallback;
177
232
  }
178
233
  }
234
+ // CEL path — everything reaching here is CEL, which is what makes this the
235
+ // right place for the objectui#5330 spelling warning: in the legacy `${…}`
236
+ // dialect above, `data.*` is the NORMAL spelling, so reporting it there would
237
+ // be a false positive on every legacy predicate. `data` names THIS row unless
238
+ // the caller is `rowless` (then the host scope keeps its own — see the
239
+ // option), which is exactly the condition the detector needs.
240
+ if (predicateText !== '(expression)') {
241
+ warnNonCanonicalRowSpelling(predicateText, rowObj, !opts.rowless, opts.label);
242
+ }
179
243
  // CEL path. The fault-aware `evalCel` costs two evaluations (to tell a fault
180
244
  // from a genuine `false`), so only pay it when a caller wants the labelled
181
245
  // fail-closed warning — the formatting hot path takes the single-eval fast
@@ -187,7 +251,7 @@ export function evalRowPredicate(pred, row, opts = {}) {
187
251
  }
188
252
  const verdict = evalCel(pred, rowObj, scope);
189
253
  if (!verdict.ok) {
190
- warnEvalError(source ?? '(expression)', opts.label, verdict.reason);
254
+ warnEvalError(predicateText, opts.label, verdict.reason);
191
255
  return fallback;
192
256
  }
193
257
  return verdict.value;
@@ -97,3 +97,58 @@ export interface CascadingOptions<T extends OptionLike> {
97
97
  * purely by `visibleWhen`). Callers format their own hint from `dependsOnFields`.
98
98
  */
99
99
  export declare function resolveCascadingOptions<T extends OptionLike>(rawOptions: readonly T[] | undefined | null, record: Record<string, unknown>, dependsOn: DependsOnInput, scope?: Record<string, unknown>): CascadingOptions<T>;
100
+ /**
101
+ * Widget keys whose OFFERED option set is re-resolved against a live record by
102
+ * {@link resolveCascadingOptions} — i.e. the allow-list of widgets a surface
103
+ * must thread its live record to (`dependentValues`). Each of their options may
104
+ * carry a `visibleWhen` predicate read against `record.*`, and the field may
105
+ * declare a `dependsOn` gate (ADR-0058 / objectui#2284 / objectui#1583).
106
+ * Without the record a cascading select never sees its controlling field and
107
+ * stays permanently gated on the "select the parent first" hint.
108
+ *
109
+ * ## One definition, three surfaces (objectui#4770)
110
+ *
111
+ * Three surfaces feed this one evaluator, and each carried its own private copy
112
+ * of these four keys until this constant existed — with no gate able to notice
113
+ * them drifting apart on what "the record" means:
114
+ *
115
+ * - the object form — `CASCADE_OPTION_FIELD_TYPES` in
116
+ * `components/src/renderers/form/form.tsx` (the original);
117
+ * - the single-record action dialog — `CASCADE_OPTION_WIDGET_TYPES` in
118
+ * `app-shell/src/views/ActionParamDialog.tsx` (objectui#3765 / PR #4756);
119
+ * - the bulk action dialog — the same name in
120
+ * `plugin-grid/src/components/BulkActionDialog.tsx` (objectui#4757 / PR #4763).
121
+ *
122
+ * The set lives in `@object-ui/core`, next to the evaluator it describes,
123
+ * because core is the one package all three already depend on — the form layer
124
+ * cannot reach `@object-ui/fields` (that dependency runs the other way). Same
125
+ * shape and same reason as `EXPANDABLE_FIELD_TYPES` in
126
+ * `core/src/utils/expand-fields.ts`, the in-repo precedent for a form-layer
127
+ * allow-table more than one surface reads. `@object-ui/fields` re-exports it
128
+ * next to `resolveFormWidgetType` for discoverability — a re-export, never a
129
+ * second definition.
130
+ *
131
+ * ## An ALLOW-LIST, not a blanket pass-through
132
+ *
133
+ * `dependentValues` is also the channel two widget-hint pickers read a specific
134
+ * SIBLING KEY from (`filter-condition` reads `object_name`, `recipient-picker`
135
+ * reads `recipient_type`), and the lookup family filters its query by it. All
136
+ * three surfaces above agree today in NOT feeding those from action / bulk
137
+ * dialog params, but that is a different wiring question from the one ruled
138
+ * here and it has never been decided either way. Stated here so the three
139
+ * copies of the statement become one — the boundary itself stays open:
140
+ * objectui#4771.
141
+ *
142
+ * ## Normalization stays with the consumer
143
+ *
144
+ * The members are WIDGET keys — `resolveFormWidgetType` output in the two
145
+ * dialogs, `normalizeFieldType` output (the `field:` prefix stripped) in the
146
+ * form — not authored `type` spellings. Each consumer keeps its own
147
+ * normalization; the two normalizations agree on these four members, which is
148
+ * what makes one shared set safe to read from both.
149
+ *
150
+ * `select` + `multiple: true` is not listed separately: `SelectField` delegates
151
+ * to `MultiSelectField` with the whole prop object, so the key it resolves
152
+ * under (`select`) is the one that must carry the record.
153
+ */
154
+ export declare const CASCADE_OPTION_WIDGET_TYPES: ReadonlySet<string>;
@@ -77,3 +77,63 @@ export function resolveCascadingOptions(rawOptions, record, dependsOn, scope) {
77
77
  const options = gated ? [] : resolveVisibleOptions(rawOptions, record, scope);
78
78
  return { options, gated, dependsOnFields };
79
79
  }
80
+ /**
81
+ * Widget keys whose OFFERED option set is re-resolved against a live record by
82
+ * {@link resolveCascadingOptions} — i.e. the allow-list of widgets a surface
83
+ * must thread its live record to (`dependentValues`). Each of their options may
84
+ * carry a `visibleWhen` predicate read against `record.*`, and the field may
85
+ * declare a `dependsOn` gate (ADR-0058 / objectui#2284 / objectui#1583).
86
+ * Without the record a cascading select never sees its controlling field and
87
+ * stays permanently gated on the "select the parent first" hint.
88
+ *
89
+ * ## One definition, three surfaces (objectui#4770)
90
+ *
91
+ * Three surfaces feed this one evaluator, and each carried its own private copy
92
+ * of these four keys until this constant existed — with no gate able to notice
93
+ * them drifting apart on what "the record" means:
94
+ *
95
+ * - the object form — `CASCADE_OPTION_FIELD_TYPES` in
96
+ * `components/src/renderers/form/form.tsx` (the original);
97
+ * - the single-record action dialog — `CASCADE_OPTION_WIDGET_TYPES` in
98
+ * `app-shell/src/views/ActionParamDialog.tsx` (objectui#3765 / PR #4756);
99
+ * - the bulk action dialog — the same name in
100
+ * `plugin-grid/src/components/BulkActionDialog.tsx` (objectui#4757 / PR #4763).
101
+ *
102
+ * The set lives in `@object-ui/core`, next to the evaluator it describes,
103
+ * because core is the one package all three already depend on — the form layer
104
+ * cannot reach `@object-ui/fields` (that dependency runs the other way). Same
105
+ * shape and same reason as `EXPANDABLE_FIELD_TYPES` in
106
+ * `core/src/utils/expand-fields.ts`, the in-repo precedent for a form-layer
107
+ * allow-table more than one surface reads. `@object-ui/fields` re-exports it
108
+ * next to `resolveFormWidgetType` for discoverability — a re-export, never a
109
+ * second definition.
110
+ *
111
+ * ## An ALLOW-LIST, not a blanket pass-through
112
+ *
113
+ * `dependentValues` is also the channel two widget-hint pickers read a specific
114
+ * SIBLING KEY from (`filter-condition` reads `object_name`, `recipient-picker`
115
+ * reads `recipient_type`), and the lookup family filters its query by it. All
116
+ * three surfaces above agree today in NOT feeding those from action / bulk
117
+ * dialog params, but that is a different wiring question from the one ruled
118
+ * here and it has never been decided either way. Stated here so the three
119
+ * copies of the statement become one — the boundary itself stays open:
120
+ * objectui#4771.
121
+ *
122
+ * ## Normalization stays with the consumer
123
+ *
124
+ * The members are WIDGET keys — `resolveFormWidgetType` output in the two
125
+ * dialogs, `normalizeFieldType` output (the `field:` prefix stripped) in the
126
+ * form — not authored `type` spellings. Each consumer keeps its own
127
+ * normalization; the two normalizations agree on these four members, which is
128
+ * what makes one shared set safe to read from both.
129
+ *
130
+ * `select` + `multiple: true` is not listed separately: `SelectField` delegates
131
+ * to `MultiSelectField` with the whole prop object, so the key it resolves
132
+ * under (`select`) is the one that must carry the record.
133
+ */
134
+ export const CASCADE_OPTION_WIDGET_TYPES = new Set([
135
+ 'select',
136
+ 'multiselect',
137
+ 'radio',
138
+ 'checkboxes',
139
+ ]);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /** The one canonical row-predicate root. */
9
+ export declare const ROW_PREDICATE_CANONICAL_ROOT = "record";
10
+ /** A non-canonical row-predicate spelling, and the edit that fixes it. */
11
+ export type NonCanonicalRowSpelling = {
12
+ /** Bare shorthand: `status == 'active'` — a field of THIS row, unrooted. */
13
+ kind: 'bare-shorthand';
14
+ /** The offending identifier (`status`). */
15
+ identifier: string;
16
+ /** What the author should write instead (`record.status`). */
17
+ canonical: string;
18
+ } | {
19
+ /** Wrong-layer root: `data.status == 'active'` on a runtime record surface. */
20
+ kind: 'metadata-layer-root';
21
+ identifier: string;
22
+ canonical: string;
23
+ };
24
+ /**
25
+ * Detect a non-canonical spelling in ONE row predicate, or `null` when the
26
+ * predicate is already canonical (or is not this module's verdict to give).
27
+ *
28
+ * Deliberately conservative in both arms — every condition below can only
29
+ * REMOVE a finding, never invent one, because a false deprecation warning sends
30
+ * an author to rewrite a predicate that was correct:
31
+ *
32
+ * - **Unparseable source** → `null`. Syntax is another gate's verdict.
33
+ * - **Bare shorthand** is reported only when the undeclared identifier is an own
34
+ * key of THIS row. That is what makes it unambiguously the #4075 shorthand
35
+ * rather than a host-scope global this module cannot see (a deployment key
36
+ * that is simply not in `SCOPE_ROOTS` would otherwise read as a bare field).
37
+ * - **`data.*`** is reported only when the caller confirms `data` names the row
38
+ * (`dataNamesRow`). A surface whose `data` is the host scope's own — a
39
+ * `rowless` dialog, a metadata-editing form — is a legitimate `data.*` site
40
+ * and is left alone.
41
+ *
42
+ * Only the FIRST finding is returned, and the bare-shorthand arm is checked
43
+ * first: it is the arm the server refuses outright (`visibility-bare-identifier`
44
+ * is an `error` there, while a mis-layered root is a `warning`), so when a
45
+ * predicate manages both it is the one the author must fix to have anything
46
+ * evaluate at all.
47
+ *
48
+ * @param source The predicate's CEL text. Callers must have already
49
+ * routed legacy `${…}`-dialect strings elsewhere — in that
50
+ * dialect `data.*` is the NORMAL spelling, and reporting it
51
+ * here would be a false positive on every legacy predicate.
52
+ * @param row The row the predicate is bound against.
53
+ * @param dataNamesRow Whether `data` is bound to that same row on this surface.
54
+ */
55
+ export declare function detectNonCanonicalRowSpelling(source: string, row: Record<string, unknown> | null | undefined, dataNamesRow: boolean): NonCanonicalRowSpelling | null;
56
+ /** Reset the row-predicate spelling warn-once memo. Exported for tests. */
57
+ export declare function resetRowPredicateCanonWarnings(): void;
58
+ /**
59
+ * Report a non-canonical row-predicate spelling once, in dev.
60
+ *
61
+ * Phase 1 of the objectui#5330 window: the binding is UNCHANGED and every
62
+ * spelling still resolves — this only says so out loud, so the stored
63
+ * population stops growing and a later survey has something to count. It is a
64
+ * warning and deliberately not a refusal: turning it into one would move the
65
+ * accept/reject set, which this card is explicitly not entitled to do.
66
+ */
67
+ export declare function warnNonCanonicalRowSpelling(source: string, row: Record<string, unknown> | null | undefined, dataNamesRow: boolean, label?: string): void;
@@ -0,0 +1,208 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * The row-predicate spelling CANON, and the Phase-1 deprecation warning for the
10
+ * two spellings that are not it (objectui#5330).
11
+ *
12
+ * ## The canon (maintainer ruling, 2026-08-20 — option B)
13
+ *
14
+ * A row predicate — `visible` / `disabled` / `enabled` on an action renderer, a
15
+ * row scope, a `record:alert` — binds the row THREE ways today: canonical
16
+ * `record.status`, bare shorthand `status`, and legacy `data.status`
17
+ * (objectui#4075 / PR #4079 bound all four action renderers all three ways to
18
+ * restore consistency, deliberately without deciding which of them is
19
+ * CONTRACT). This module is where that decision now lives:
20
+ *
21
+ * > **The canon is `record.*`.** The bare shorthand and `data.*` are
22
+ * > client-side tolerances in a deprecation window, kept because stored
23
+ * > metadata carries them, warned about here, and removable only after a
24
+ * > stored-metadata survey sizes the window (⛔ no removal before the survey).
25
+ *
26
+ * It mirrors the objectstack#7917 option-② precedent for the identical shape (a
27
+ * renderer tolerance quietly becoming a second de-facto contract — AGENTS.md
28
+ * #0.1), whose objectui half is `utils/dashboard-filters.ts`' bare-string
29
+ * `options` shorthand: same three phases, same reason the warning is not
30
+ * decoration (ADR-0078 — nothing silently inert; a tolerance nothing ever
31
+ * reports can never be retired, because nothing would ever show that the last
32
+ * document carrying it is gone).
33
+ *
34
+ * ## The canon states the SERVER's accept set, not this client's
35
+ *
36
+ * The ruling made that the dev's first measurement, because a canon that only
37
+ * describes the renderer would be the very thing it exists to end. Measured
38
+ * against `@objectstack/formula@17.1.0` — the engine the server evaluates with,
39
+ * and the one `fieldRules.ts` already delegates to:
40
+ *
41
+ * | spelling | server runtime (`buildScope` + `celEngine`) | server authoring oracle |
42
+ * |---|---|---|
43
+ * | `record.status` | ✅ `{ ok: true, value: true }` | ✅ accepted |
44
+ * | bare `status` | ❌ `Unknown variable: status` | ❌ refused (`'status'`) |
45
+ * | `data.status` | ❌ `Unknown variable: data` | ⚠️ **silently accepted** |
46
+ *
47
+ * `buildScope({ record })` mounts exactly `['record']` — `data` is never bound
48
+ * and the row's fields are never flattened to top level. So **the server
49
+ * accepts `record.*` and nothing else**; both other spellings fault there. The
50
+ * three-way binding is a client tolerance with no server counterpart, which is
51
+ * precisely why it is the client's job to warn.
52
+ *
53
+ * ⚠️ The `data.*` row is the dangerous one and the reason this warning exists
54
+ * at all. `data` IS in `@objectstack/formula`'s `SCOPE_ROOTS`, so the server's
55
+ * bare-identifier oracle waves `data.status` through — that list is a
56
+ * deliberately generous "never faults" LINT BASELINE, not the runtime accept
57
+ * set. A `data.*` row predicate therefore passes every authoring gate the
58
+ * platform has and then binds nothing at runtime: it is not an error, it is a
59
+ * constant `false`, and a `visible` that is constantly false is a button that
60
+ * silently never appears. That is the #4075 fail-closed family's exact
61
+ * signature, and this client is the only layer positioned to catch it.
62
+ *
63
+ * ## `data.*` is DEPRECATED HERE, not everywhere — the canon is layer-scoped
64
+ *
65
+ * `data` is the CANONICAL root one layer over, in a metadata-editing form (the
66
+ * row under edit): objectstack's `CANONICAL_ROOT_BY_LAYER` reads
67
+ * `{ runtime: 'record', metadata: 'data' }` (ADR-0089 D3), and objectui's own
68
+ * `app-shell` metadata-admin `SchemaForm` binds `{ data: row }` on purpose. So
69
+ * `data.*` is not a legacy alias to be deprecated platform-wide — it is a
70
+ * WRONG-LAYER paste on a runtime record surface, and only that is what this
71
+ * module reports. Stating the deprecation unqualified would contradict
72
+ * ADR-0089 D3 and break the metadata-editing layer's own contract.
73
+ *
74
+ * ## Why the detection reuses the server's oracle instead of a regex
75
+ *
76
+ * `collectCelRootIdentifiers` and `firstUndeclaredReference` are
77
+ * `@objectstack/formula` exports — the same two oracles objectstack's
78
+ * `visibility-bare-identifier` gate uses. Reading roots off the canonical AST
79
+ * rather than pattern-matching the source is what keeps this from inventing a
80
+ * second dialect judgement client-side: a string this module cannot parse is
81
+ * NOT this module's verdict to give (syntax belongs to the gates that own it),
82
+ * and it stands down rather than guessing.
83
+ */
84
+ import { collectCelRootIdentifiers, firstUndeclaredReference } from '@objectstack/formula';
85
+ /** The one canonical row-predicate root. */
86
+ export const ROW_PREDICATE_CANONICAL_ROOT = 'record';
87
+ /**
88
+ * The metadata-editing-form root (ADR-0089 D3). Canonical on THAT layer, and a
89
+ * wrong-layer paste on a runtime record surface — see the module note.
90
+ */
91
+ const METADATA_LAYER_ROOT = 'data';
92
+ /**
93
+ * Detect a non-canonical spelling in ONE row predicate, or `null` when the
94
+ * predicate is already canonical (or is not this module's verdict to give).
95
+ *
96
+ * Deliberately conservative in both arms — every condition below can only
97
+ * REMOVE a finding, never invent one, because a false deprecation warning sends
98
+ * an author to rewrite a predicate that was correct:
99
+ *
100
+ * - **Unparseable source** → `null`. Syntax is another gate's verdict.
101
+ * - **Bare shorthand** is reported only when the undeclared identifier is an own
102
+ * key of THIS row. That is what makes it unambiguously the #4075 shorthand
103
+ * rather than a host-scope global this module cannot see (a deployment key
104
+ * that is simply not in `SCOPE_ROOTS` would otherwise read as a bare field).
105
+ * - **`data.*`** is reported only when the caller confirms `data` names the row
106
+ * (`dataNamesRow`). A surface whose `data` is the host scope's own — a
107
+ * `rowless` dialog, a metadata-editing form — is a legitimate `data.*` site
108
+ * and is left alone.
109
+ *
110
+ * Only the FIRST finding is returned, and the bare-shorthand arm is checked
111
+ * first: it is the arm the server refuses outright (`visibility-bare-identifier`
112
+ * is an `error` there, while a mis-layered root is a `warning`), so when a
113
+ * predicate manages both it is the one the author must fix to have anything
114
+ * evaluate at all.
115
+ *
116
+ * @param source The predicate's CEL text. Callers must have already
117
+ * routed legacy `${…}`-dialect strings elsewhere — in that
118
+ * dialect `data.*` is the NORMAL spelling, and reporting it
119
+ * here would be a false positive on every legacy predicate.
120
+ * @param row The row the predicate is bound against.
121
+ * @param dataNamesRow Whether `data` is bound to that same row on this surface.
122
+ */
123
+ export function detectNonCanonicalRowSpelling(source, row, dataNamesRow) {
124
+ if (typeof source !== 'string' || !source.trim())
125
+ return null;
126
+ const roots = collectCelRootIdentifiers(source);
127
+ // Not parseable through the canonical front end — not our verdict.
128
+ if (!roots || roots.ok !== true)
129
+ return null;
130
+ // (1) Bare shorthand — the arm the server refuses outright.
131
+ const bare = firstUndeclaredReference(source);
132
+ if (typeof bare === 'string' &&
133
+ row != null &&
134
+ typeof row === 'object' &&
135
+ Object.prototype.hasOwnProperty.call(row, bare)) {
136
+ return {
137
+ kind: 'bare-shorthand',
138
+ identifier: bare,
139
+ canonical: `${ROW_PREDICATE_CANONICAL_ROOT}.${bare}`,
140
+ };
141
+ }
142
+ // (2) Wrong-layer `data.*` root — silent at runtime on the server.
143
+ if (dataNamesRow && Array.isArray(roots.roots) && roots.roots.includes(METADATA_LAYER_ROOT)) {
144
+ return {
145
+ kind: 'metadata-layer-root',
146
+ identifier: METADATA_LAYER_ROOT,
147
+ canonical: ROW_PREDICATE_CANONICAL_ROOT,
148
+ };
149
+ }
150
+ return null;
151
+ }
152
+ /**
153
+ * Dev-mode gate, matching `utils/dashboard-filters.ts` and `actions/actionKeys.ts`
154
+ * — a deprecation warning that floods a production console is a warning that
155
+ * gets muted.
156
+ */
157
+ const isDev = () => globalThis.process?.env
158
+ ?.NODE_ENV !== 'production';
159
+ /**
160
+ * Warn-once memo, keyed by the `(label, predicate source)` pair — the same
161
+ * identity `warnEvalError` uses, and JSON-encoded for the same reason: the
162
+ * separator that boundary once used was a raw U+0000, which made the file
163
+ * carrying it binary to grep (objectstack#5450). Keying on the source alone
164
+ * would report the first surface carrying a shorthand `status` predicate and
165
+ * stay silent about every other one; the label is what sends the author to the
166
+ * right screen.
167
+ *
168
+ * Module scope, not per-call: these predicates are re-evaluated on every row of
169
+ * every render, so per-call state would warn once per frame — the flood the
170
+ * dedupe exists to prevent.
171
+ */
172
+ const warnedSpellings = new Set();
173
+ /** Reset the row-predicate spelling warn-once memo. Exported for tests. */
174
+ export function resetRowPredicateCanonWarnings() {
175
+ warnedSpellings.clear();
176
+ }
177
+ /**
178
+ * Report a non-canonical row-predicate spelling once, in dev.
179
+ *
180
+ * Phase 1 of the objectui#5330 window: the binding is UNCHANGED and every
181
+ * spelling still resolves — this only says so out loud, so the stored
182
+ * population stops growing and a later survey has something to count. It is a
183
+ * warning and deliberately not a refusal: turning it into one would move the
184
+ * accept/reject set, which this card is explicitly not entitled to do.
185
+ */
186
+ export function warnNonCanonicalRowSpelling(source, row, dataNamesRow, label) {
187
+ if (!isDev())
188
+ return;
189
+ const finding = detectNonCanonicalRowSpelling(source, row, dataNamesRow);
190
+ if (!finding)
191
+ return;
192
+ const key = JSON.stringify([label ?? '', source, finding.kind]);
193
+ if (warnedSpellings.has(key))
194
+ return;
195
+ warnedSpellings.add(key);
196
+ const where = label ? ` (${label})` : '';
197
+ const detail = finding.kind === 'bare-shorthand'
198
+ ? `it references the bare field ${JSON.stringify(finding.identifier)}; ` +
199
+ `the server refuses that spelling outright ("Unknown variable: ${finding.identifier}")`
200
+ : 'it is rooted at `data.`, which is the metadata-editing-form root — on a ' +
201
+ 'record surface the server binds no `data` at all, so the predicate is a ' +
202
+ 'constant false there rather than an error';
203
+ console.warn(`[object-ui] A row predicate${where} uses a DEPRECATED spelling: ` +
204
+ `${JSON.stringify(source)} — ${detail}. The canon is \`record.*\` ` +
205
+ `(objectui#5330, ruled 2026-08-20): write \`${finding.canonical}\`. ` +
206
+ 'This still evaluates here for now; the tolerance retires after a ' +
207
+ 'stored-metadata survey.');
208
+ }
package/dist/index.d.ts CHANGED
@@ -18,6 +18,8 @@ export * from './utils/filter-converter.js';
18
18
  export * from './utils/managedBy.js';
19
19
  export * from './utils/extract-records.js';
20
20
  export * from './utils/expand-fields.js';
21
+ export * from './utils/retired-field-types.js';
22
+ export * from './utils/unmaterialized-fields.js';
21
23
  export * from './utils/column-identity.js';
22
24
  export * from './utils/sort-values.js';
23
25
  export * from './utils/sort-query.js';
@@ -49,6 +51,7 @@ export * from './utils/dashboard-filters.js';
49
51
  export * from './utils/merge-filters.js';
50
52
  export * from './utils/compare-to.js';
51
53
  export * from './utils/chart-series.js';
54
+ export * from './utils/chart-presentation.js';
52
55
  export * from './utils/number-display.js';
53
56
  export * from './utils/dataset-format.js';
54
57
  export * from './utils/dataset-pivot.js';
@@ -58,3 +61,4 @@ export * from './utils/reference-keys.js';
58
61
  export * from './utils/predicate-record.js';
59
62
  export * from './utils/predicate-fields.js';
60
63
  export * from './utils/normalize-list-view.js';
64
+ export * from './utils/humanize-label.js';
package/dist/index.js CHANGED
@@ -22,6 +22,13 @@ export * from './utils/filter-converter.js';
22
22
  export * from './utils/managedBy.js';
23
23
  export * from './utils/extract-records.js';
24
24
  export * from './utils/expand-fields.js';
25
+ // The RETIREMENT gate (objectui#4914, maintainer ruling B). Homed here rather
26
+ // than in `@object-ui/fields` because `@object-ui/components` is one of its six
27
+ // consumers and `fields` depends on `components` — see the module's docblock
28
+ // for why a second copy was not an option. `@object-ui/fields` re-exports every
29
+ // name, so its published surface is unchanged.
30
+ export * from './utils/retired-field-types.js';
31
+ export * from './utils/unmaterialized-fields.js';
25
32
  export * from './utils/column-identity.js';
26
33
  export * from './utils/sort-values.js';
27
34
  export * from './utils/sort-query.js';
@@ -56,6 +63,10 @@ export * from './utils/dashboard-filters.js';
56
63
  export * from './utils/merge-filters.js';
57
64
  export * from './utils/compare-to.js';
58
65
  export * from './utils/chart-series.js';
66
+ // The AUTHORED half of a dataset-bound chart (objectui#4229's data/presentation
67
+ // split), shared by the dashboard widget and the report's embedded chart so the
68
+ // same spec keys are lowered identically on both (objectui#4877).
69
+ export * from './utils/chart-presentation.js';
59
70
  // The ONE number-display formatter (objectui#4033) — grouping policy, display
60
71
  // locale and the percent convention. It lived in `@object-ui/i18n` until
61
72
  // objectui#4576; it is pure, and living above `core` was what kept
@@ -79,3 +90,9 @@ export * from './utils/predicate-record.js';
79
90
  // which the column-derived `$select` never asked the server for.
80
91
  export * from './utils/predicate-fields.js';
81
92
  export * from './utils/normalize-list-view.js';
93
+ // The single home for the VALUE fallback prettifier (a stored value becomes a
94
+ // display string when nothing resolves it). `@object-ui/fields` and
95
+ // `@object-ui/plugin-charts` each carried a byte-identical private copy;
96
+ // both now re-export this one (objectui#5444). Its docstring also records why
97
+ // it stays distinct from `humanizeFieldKey`, the KEY fallback.
98
+ export * from './utils/humanize-label.js';