@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
@@ -30,6 +30,12 @@
30
30
  * more column and no extra round trip. (A relation that IS displayed still gets
31
31
  * expanded for its label, and `toPredicateRecord` collapses it back for the
32
32
  * predicate.)
33
+ *
34
+ * Since objectstack#8018 the same reader also carries the non-predicate row keys
35
+ * a view's actions read — today just `recordIdField`. The name kept the
36
+ * "predicate" spelling because the mechanism is identical (harvest a name, gate
37
+ * it against the declared fields, add it to `$select`); see
38
+ * {@link listViewPredicates} for what is in the set and why.
33
39
  */
34
40
  /**
35
41
  * The columns the framework provisions on EVERY business object
@@ -71,13 +77,32 @@ export declare function isProjectableField(name: string, declaredFields: Record<
71
77
  */
72
78
  export declare function collectPredicateFieldRefs(predicates: readonly unknown[]): string[];
73
79
  /**
74
- * Every predicate a list-ish view carries, flattened for
80
+ * Every record-field reference a list-ish view carries, flattened for
75
81
  * {@link collectPredicateFieldRefs}.
76
82
  *
77
83
  * One reader so the projection cannot fall behind the surfaces: each entry here
78
- * is a place a predicate is actually evaluated against a row — conditional
79
- * formatting, the row kebab (custom defs AND the built-in Edit/Delete
80
- * overrides), the selection bar, and the object's declared actions.
84
+ * is a place a row field is actually READ — conditional formatting, the row
85
+ * kebab (custom defs AND the built-in Edit/Delete overrides), the selection bar,
86
+ * and the object's declared actions.
87
+ *
88
+ * Most entries are predicates. Two are not, and both are spelled as a synthetic
89
+ * `record.<name>` so the one harvester handles them: conditional formatting's
90
+ * native `{ field, operator, value }` shape, and an action's `recordIdField`
91
+ * (objectstack#8018). The `recordIdField` case is the same *class* of bug the
92
+ * predicate harvest exists to close, one surface over — the action runtime reads
93
+ * `rowRecord[action.recordIdField]` to seed `recordIdParam`, so a key outside the
94
+ * listView columns arrived absent and the injection was silently skipped, which
95
+ * turns a record-scoped mutation into one that names no record while still
96
+ * reporting success. The default (`id`) is already projected unconditionally, so
97
+ * only an explicit declaration adds anything here.
98
+ *
99
+ * A `recordIdField` that is not a bare identifier is dropped here, and one naming
100
+ * a field the object does not declare is dropped by the caller's
101
+ * `isProjectableField` guard. Both drops are the safe direction: such a name is
102
+ * not a column anywhere, and an unknown key in `$select` is not ignored by every
103
+ * backend. The identifier gate is not decoration — without it the harvester's
104
+ * regex would read a PREFIX of a malformed name (`record.not a field` → `not`)
105
+ * and contribute a plausible wrong field instead of nothing.
81
106
  *
82
107
  * `objectActions` is deliberately the object's WHOLE action set rather than
83
108
  * only the ones this view promotes. Narrowing it would mean re-running the
@@ -30,6 +30,12 @@
30
30
  * more column and no extra round trip. (A relation that IS displayed still gets
31
31
  * expanded for its label, and `toPredicateRecord` collapses it back for the
32
32
  * predicate.)
33
+ *
34
+ * Since objectstack#8018 the same reader also carries the non-predicate row keys
35
+ * a view's actions read — today just `recordIdField`. The name kept the
36
+ * "predicate" spelling because the mechanism is identical (harvest a name, gate
37
+ * it against the declared fields, add it to `$select`); see
38
+ * {@link listViewPredicates} for what is in the set and why.
33
39
  */
34
40
  /**
35
41
  * The columns the framework provisions on EVERY business object
@@ -74,6 +80,14 @@ export function isProjectableField(name, declaredFields) {
74
80
  * record-header path), so this covers the documented spelling.
75
81
  */
76
82
  const RECORD_REF = /\b(?:record|data)\.([A-Za-z_][A-Za-z0-9_]*)/g;
83
+ /**
84
+ * A whole bare identifier — what a field name is, and the only shape that can be
85
+ * spelled as `record.<name>` without the harvester's regex reading a PREFIX of it
86
+ * as the name (`record.not a field` would otherwise yield `not`). Used to gate
87
+ * the non-predicate names folded into {@link listViewPredicates}, so a malformed
88
+ * declaration contributes nothing instead of contributing a plausible wrong name.
89
+ */
90
+ const BARE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
77
91
  /** Pull the CEL source out of any of the shapes a predicate is authored in. */
78
92
  function predicateSource(pred) {
79
93
  if (typeof pred === 'string')
@@ -117,13 +131,32 @@ export function collectPredicateFieldRefs(predicates) {
117
131
  return out;
118
132
  }
119
133
  /**
120
- * Every predicate a list-ish view carries, flattened for
134
+ * Every record-field reference a list-ish view carries, flattened for
121
135
  * {@link collectPredicateFieldRefs}.
122
136
  *
123
137
  * One reader so the projection cannot fall behind the surfaces: each entry here
124
- * is a place a predicate is actually evaluated against a row — conditional
125
- * formatting, the row kebab (custom defs AND the built-in Edit/Delete
126
- * overrides), the selection bar, and the object's declared actions.
138
+ * is a place a row field is actually READ — conditional formatting, the row
139
+ * kebab (custom defs AND the built-in Edit/Delete overrides), the selection bar,
140
+ * and the object's declared actions.
141
+ *
142
+ * Most entries are predicates. Two are not, and both are spelled as a synthetic
143
+ * `record.<name>` so the one harvester handles them: conditional formatting's
144
+ * native `{ field, operator, value }` shape, and an action's `recordIdField`
145
+ * (objectstack#8018). The `recordIdField` case is the same *class* of bug the
146
+ * predicate harvest exists to close, one surface over — the action runtime reads
147
+ * `rowRecord[action.recordIdField]` to seed `recordIdParam`, so a key outside the
148
+ * listView columns arrived absent and the injection was silently skipped, which
149
+ * turns a record-scoped mutation into one that names no record while still
150
+ * reporting success. The default (`id`) is already projected unconditionally, so
151
+ * only an explicit declaration adds anything here.
152
+ *
153
+ * A `recordIdField` that is not a bare identifier is dropped here, and one naming
154
+ * a field the object does not declare is dropped by the caller's
155
+ * `isProjectableField` guard. Both drops are the safe direction: such a name is
156
+ * not a column anywhere, and an unknown key in `$select` is not ignored by every
157
+ * backend. The identifier gate is not decoration — without it the harvester's
158
+ * regex would read a PREFIX of a malformed name (`record.not a field` → `not`)
159
+ * and contribute a plausible wrong field instead of nothing.
127
160
  *
128
161
  * `objectActions` is deliberately the object's WHOLE action set rather than
129
162
  * only the ones this view promotes. Narrowing it would mean re-running the
@@ -151,6 +184,13 @@ export function listViewPredicates(view) {
151
184
  continue;
152
185
  const d = def;
153
186
  preds.push(d.visible, d.disabled);
187
+ // The row key this action identifies its record by — a field the runtime
188
+ // READS off the row, so the projection owes it exactly as it owes a
189
+ // predicate's operands. Spelled as a `record.` reference so the one
190
+ // harvester handles it (see the doc above).
191
+ if (typeof d.recordIdField === 'string' && BARE_IDENTIFIER.test(d.recordIdField)) {
192
+ preds.push(`record.${d.recordIdField}`);
193
+ }
154
194
  }
155
195
  }
156
196
  for (const override of Object.values(view.userActions ?? {})) {
@@ -0,0 +1,90 @@
1
+ /**
2
+ * ObjectUI — retired field-type spellings
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
+ * TOMBSTONE table — field-type spellings this renderer has RETIRED, mapped to
10
+ * the prescription an author must follow instead (ADR-0049 enforce-or-remove).
11
+ *
12
+ * A retired spelling is not merely absent: absence here means
13
+ * `mapFieldTypeToFormType`'s `|| 'field:text'` tail would hand back a
14
+ * working plain text input, which is the failure mode this table exists to
15
+ * prevent. An author who writes a retired name — or an AI author who copies one
16
+ * out of a stale doc — must be TOLD, not quietly given a text box that looks
17
+ * like it worked. So each entry resolves to a registered tombstone widget that
18
+ * renders a visible refusal naming the migration
19
+ * (`packages/fields/src/index.tsx`), while {@link reportRetiredFieldType}
20
+ * writes the same prescription to the console.
21
+ *
22
+ * `owner` (objectui#4814, ruling A′): a synonym for `user` with zero behavioral
23
+ * delta — both resolved to the SAME `UserField` widget — and absent from the
24
+ * spec's closed 48-member `FieldType`, so no object schema could ever declare
25
+ * it; it was reachable only through hand-written SDUI. Three code faces had
26
+ * already drifted apart on this one word (the form's data-source rule excluded
27
+ * it while plugin-grid's bulk dialog included it), which is the standing
28
+ * evidence that a second spelling for one concept is a drift channel, not a
29
+ * convenience. The idiom survives verbatim as `{ type: 'user', name: 'owner' }`.
30
+ *
31
+ * ## Why this table lives in `@object-ui/core` and not in `@object-ui/fields`
32
+ *
33
+ * It was born in `packages/fields/src/field-type-alias.ts`, next to the widget
34
+ * map that is its first consumer, and `@object-ui/fields` still exports every
35
+ * name in this module so no consumer of that surface had to move. It was HOISTED
36
+ * here by objectui#4914, and for a measured reason rather than a tidiness one:
37
+ * the retirement gate has to run ahead of six predicate faces, and one of them
38
+ * — `operatorsForFieldType` / the filter row's control choice in
39
+ * `packages/components/src/custom/filter-builder.tsx` — lives in
40
+ * `@object-ui/components`, which `@object-ui/fields` DEPENDS ON (54 source
41
+ * files import from it). A `components → fields` import is a package cycle, so
42
+ * the six faces could not all read one table while that table lived in
43
+ * `fields`.
44
+ *
45
+ * The alternative was a second copy of the table inside `components`, and that
46
+ * is precisely what this file exists to make impossible: two tables means two
47
+ * `reportedRetiredTypes` sets, so one retired spelling logs its prescription
48
+ * TWICE — and the "fires once" half of the ruling would be false the day it
49
+ * landed. `@object-ui/core` is below all six faces' packages (it imports only
50
+ * `@object-ui/types`), and already owns the sibling cross-package field-type
51
+ * vocabulary — {@link EXPANDABLE_FIELD_TYPES} in `./expand-fields.js`, whose
52
+ * own docblock records what happened the last time this kind of table was
53
+ * hand-copied per consumer. One table, one home, seven consumers.
54
+ */
55
+ export declare const RETIRED_FIELD_TYPES: Readonly<Record<string, string>>;
56
+ /**
57
+ * THE retirement gate — true when a spelling has been retired by this renderer.
58
+ *
59
+ * Ruled into existence by the maintainer on 2026-08-18 (objectui#4914, option
60
+ * B) as the ONE question every field-type predicate asks before it answers:
61
+ * "a retired spelling fires `reportRetiredFieldType` once and takes the
62
+ * established disposition, instead of riding the lookup-family paths or
63
+ * silently degrading."
64
+ *
65
+ * Quantified over the whole table rather than written per spelling, which is
66
+ * the entire point of having a gate at all: the next retirement closes every
67
+ * face the day it lands in {@link RETIRED_FIELD_TYPES}, instead of leaving each
68
+ * consumer open until someone notices it. `owner` survived objectui#4814 in
69
+ * thirteen more faces exactly because each one had hand-written its own
70
+ * membership list.
71
+ *
72
+ * `hasOwnProperty`, not `RETIRED_FIELD_TYPES[type]`: a `type` of
73
+ * `'constructor'` or `'toString'` reaches `Object.prototype` and would answer
74
+ * truthy through a plain index read.
75
+ *
76
+ * @param type - The spelling an author wrote (or a backend column declared).
77
+ * @returns `true` when the spelling is retired. Pure — it does NOT log; pair it
78
+ * with {@link reportRetiredFieldType} at the face that wants to be loud.
79
+ */
80
+ export declare function isRetiredFieldType(type: string | undefined | null): boolean;
81
+ /**
82
+ * Report a retired field-type spelling loudly, once per spelling.
83
+ *
84
+ * @param fieldType - The spelling the author wrote.
85
+ * @returns `true` when `fieldType` is retired (whether or not this call was the
86
+ * one that logged), so callers can branch on it without a second table lookup.
87
+ */
88
+ export declare function reportRetiredFieldType(fieldType: string): boolean;
89
+ /** Test seam — forget which spellings have been reported. */
90
+ export declare function resetRetiredFieldTypeReports(): void;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * ObjectUI — retired field-type spellings
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
+ * TOMBSTONE table — field-type spellings this renderer has RETIRED, mapped to
10
+ * the prescription an author must follow instead (ADR-0049 enforce-or-remove).
11
+ *
12
+ * A retired spelling is not merely absent: absence here means
13
+ * `mapFieldTypeToFormType`'s `|| 'field:text'` tail would hand back a
14
+ * working plain text input, which is the failure mode this table exists to
15
+ * prevent. An author who writes a retired name — or an AI author who copies one
16
+ * out of a stale doc — must be TOLD, not quietly given a text box that looks
17
+ * like it worked. So each entry resolves to a registered tombstone widget that
18
+ * renders a visible refusal naming the migration
19
+ * (`packages/fields/src/index.tsx`), while {@link reportRetiredFieldType}
20
+ * writes the same prescription to the console.
21
+ *
22
+ * `owner` (objectui#4814, ruling A′): a synonym for `user` with zero behavioral
23
+ * delta — both resolved to the SAME `UserField` widget — and absent from the
24
+ * spec's closed 48-member `FieldType`, so no object schema could ever declare
25
+ * it; it was reachable only through hand-written SDUI. Three code faces had
26
+ * already drifted apart on this one word (the form's data-source rule excluded
27
+ * it while plugin-grid's bulk dialog included it), which is the standing
28
+ * evidence that a second spelling for one concept is a drift channel, not a
29
+ * convenience. The idiom survives verbatim as `{ type: 'user', name: 'owner' }`.
30
+ *
31
+ * ## Why this table lives in `@object-ui/core` and not in `@object-ui/fields`
32
+ *
33
+ * It was born in `packages/fields/src/field-type-alias.ts`, next to the widget
34
+ * map that is its first consumer, and `@object-ui/fields` still exports every
35
+ * name in this module so no consumer of that surface had to move. It was HOISTED
36
+ * here by objectui#4914, and for a measured reason rather than a tidiness one:
37
+ * the retirement gate has to run ahead of six predicate faces, and one of them
38
+ * — `operatorsForFieldType` / the filter row's control choice in
39
+ * `packages/components/src/custom/filter-builder.tsx` — lives in
40
+ * `@object-ui/components`, which `@object-ui/fields` DEPENDS ON (54 source
41
+ * files import from it). A `components → fields` import is a package cycle, so
42
+ * the six faces could not all read one table while that table lived in
43
+ * `fields`.
44
+ *
45
+ * The alternative was a second copy of the table inside `components`, and that
46
+ * is precisely what this file exists to make impossible: two tables means two
47
+ * `reportedRetiredTypes` sets, so one retired spelling logs its prescription
48
+ * TWICE — and the "fires once" half of the ruling would be false the day it
49
+ * landed. `@object-ui/core` is below all six faces' packages (it imports only
50
+ * `@object-ui/types`), and already owns the sibling cross-package field-type
51
+ * vocabulary — {@link EXPANDABLE_FIELD_TYPES} in `./expand-fields.js`, whose
52
+ * own docblock records what happened the last time this kind of table was
53
+ * hand-copied per consumer. One table, one home, seven consumers.
54
+ */
55
+ export const RETIRED_FIELD_TYPES = Object.freeze({
56
+ owner: "[object-ui] Field type `owner` was RETIRED (objectui#4814). It was a synonym " +
57
+ "for `user` with no behavioral difference, and it is not a member of " +
58
+ "`@objectstack/spec`'s FieldType. Write the record-owner field as " +
59
+ "`{ type: 'user', name: 'owner' }` — the field NAME carries the ownership " +
60
+ "meaning, the type carries the widget. The `widget: 'field:owner'` spelling " +
61
+ "is retired with it.",
62
+ });
63
+ /**
64
+ * THE retirement gate — true when a spelling has been retired by this renderer.
65
+ *
66
+ * Ruled into existence by the maintainer on 2026-08-18 (objectui#4914, option
67
+ * B) as the ONE question every field-type predicate asks before it answers:
68
+ * "a retired spelling fires `reportRetiredFieldType` once and takes the
69
+ * established disposition, instead of riding the lookup-family paths or
70
+ * silently degrading."
71
+ *
72
+ * Quantified over the whole table rather than written per spelling, which is
73
+ * the entire point of having a gate at all: the next retirement closes every
74
+ * face the day it lands in {@link RETIRED_FIELD_TYPES}, instead of leaving each
75
+ * consumer open until someone notices it. `owner` survived objectui#4814 in
76
+ * thirteen more faces exactly because each one had hand-written its own
77
+ * membership list.
78
+ *
79
+ * `hasOwnProperty`, not `RETIRED_FIELD_TYPES[type]`: a `type` of
80
+ * `'constructor'` or `'toString'` reaches `Object.prototype` and would answer
81
+ * truthy through a plain index read.
82
+ *
83
+ * @param type - The spelling an author wrote (or a backend column declared).
84
+ * @returns `true` when the spelling is retired. Pure — it does NOT log; pair it
85
+ * with {@link reportRetiredFieldType} at the face that wants to be loud.
86
+ */
87
+ export function isRetiredFieldType(type) {
88
+ if (typeof type !== 'string' || !type)
89
+ return false;
90
+ return Object.prototype.hasOwnProperty.call(RETIRED_FIELD_TYPES, type);
91
+ }
92
+ /**
93
+ * Spellings already reported this session, so a retired type inside a rendered
94
+ * list logs its prescription ONCE instead of once per row. The message is a
95
+ * fix instruction for an author, not a per-render event — a 1000-row grid
96
+ * repeating it 1000 times buries the very thing it is trying to surface.
97
+ *
98
+ * Module state, and that is load bearing now that SEVEN faces call the reporter
99
+ * (objectui#4914): the dedupe is per SPELLING, not per face, so a filter row
100
+ * whose column is refused by `operatorsForFieldType`, by the control chooser
101
+ * and by `normalizeFieldType` on the same render still prints one line.
102
+ */
103
+ const reportedRetiredTypes = new Set();
104
+ /**
105
+ * Report a retired field-type spelling loudly, once per spelling.
106
+ *
107
+ * @param fieldType - The spelling the author wrote.
108
+ * @returns `true` when `fieldType` is retired (whether or not this call was the
109
+ * one that logged), so callers can branch on it without a second table lookup.
110
+ */
111
+ export function reportRetiredFieldType(fieldType) {
112
+ const prescription = isRetiredFieldType(fieldType) ? RETIRED_FIELD_TYPES[fieldType] : undefined;
113
+ if (!prescription)
114
+ return false;
115
+ if (!reportedRetiredTypes.has(fieldType)) {
116
+ reportedRetiredTypes.add(fieldType);
117
+ console.error(prescription);
118
+ }
119
+ return true;
120
+ }
121
+ /** Test seam — forget which spellings have been reported. */
122
+ export function resetRetiredFieldTypeReports() {
123
+ reportedRetiredTypes.clear();
124
+ }
@@ -16,21 +16,23 @@
16
16
  * accepts four shapes; normalizing here means one shape reaches the wire and a
17
17
  * block does not have to know which spelling its author used.
18
18
  *
19
- * This is the same conversion three sibling blocks already inline privately
20
- * (`ObjectGantt` / `ObjectMap` / `ObjectCalendar`, byte-identical copies). It is
21
- * hoisted here because objectstack#7137 added two more read sites
22
- * (`object-timeline`, `record:line_items`), and a fifth and sixth private copy is
23
- * how the conversions start disagreeing. Collapsing the three existing copies
24
- * onto this one is deliberately NOT part of #7137 — it is tracked as
25
- * objectstack#7148.
19
+ * This is now the ONLY definition in the repo. It was hoisted here because
20
+ * objectstack#7137 added two more read sites (`object-timeline`,
21
+ * `record:line_items`) next to three sibling blocks that each inlined a
22
+ * byte-identical private copy (`ObjectGantt` / `ObjectMap` / `ObjectCalendar`),
23
+ * and a fifth and sixth copy is how the conversions start disagreeing. Those
24
+ * three copies were deliberately left alone by #7137 and collapsed onto this
25
+ * function by objectui#4022; every block now imports it from here.
26
26
  *
27
- * Two deliberate differences from those private copies, both of which make this
28
- * function more faithful to the declared contract rather than adding tolerance:
27
+ * Two deliberate differences from those retired private copies, both of which
28
+ * make this function more faithful to the declared contract rather than adding
29
+ * tolerance — and both are BEHAVIOUR CHANGES the migration delivered, not pure
30
+ * refactor:
29
31
  *
30
32
  * - **`order` is optional in `SortConfig`**, so an entry that omits it means
31
33
  * ascending (that is what `$orderby`'s own
32
34
  * `Array<{ field: string; order?: 'asc' | 'desc' }>` shape says). The private
33
- * copies require BOTH keys and silently drop such an entry, which loses an
35
+ * copies required BOTH keys and silently dropped such an entry, which lost an
34
36
  * authored sort key instead of ordering by it.
35
37
  * - **Nothing usable yields `undefined`, never `{}`.** An empty object is a
36
38
  * truthy value that means "no ordering" only by accident of the adapter's
@@ -0,0 +1,52 @@
1
+ /**
2
+ * ObjectUI — unmaterialized-fields utility
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
+ * Field types no driver materialises a stored column for — the value is
10
+ * computed on read (objectui#3950).
11
+ *
12
+ * A STORAGE fact, not a taste judgement, and it decides what the SERVER can be
13
+ * asked to do with such a field. All three query axes have now closed on it:
14
+ *
15
+ * - SORT — refused since objectstack#6994 (`400 INVALID_SORT`). Before that it
16
+ * degraded in silence: the response carried the very values it was asked to
17
+ * order by, out of order, under a `200`, with ascending and descending
18
+ * byte-identical (measured on a real SQL driver).
19
+ * - FILTER — refused since objectstack#8296 (`400 INVALID_FIELD`); previously
20
+ * `200` with zero rows whichever way the predicate pointed.
21
+ * - SEARCH — refused by name since objectstack#6674.
22
+ *
23
+ * The membership is bound to `@objectstack/spec` instead of being written out
24
+ * here, on the reasoning the platform's own filter door applies to itself: a
25
+ * layer that re-decided "which types have a column" drifts from the drivers,
26
+ * and then either withholds an affordance that works or offers one that 400s.
27
+ * The spec pins the set to `formula` alone.
28
+ *
29
+ * Deliberately NOT `COMPUTED_VALUE_TYPES` (`formula` / `summary` /
30
+ * `autonumber`) — that is the WRITE contract ("never client-written"). A
31
+ * `summary` and an `autonumber` each get a real maintained column and order,
32
+ * filter and search correctly, so widening to that set would withhold
33
+ * affordances from two types that work. The platform's own conformance suite
34
+ * pins that trap by name.
35
+ *
36
+ * SCOPE on the sort axis: this rules out a sort the SERVER performs. A
37
+ * client-side sort keys off the value the cell renders — which the server
38
+ * hydrates on read — so it orders by exactly what the user sees and stays
39
+ * honest. Consumers therefore apply this where they already apply the
40
+ * relational carve-out (see {@link EXPANDABLE_FIELD_TYPES}): under server
41
+ * sorting only.
42
+ */
43
+ export declare const UNMATERIALIZED_FIELD_TYPES: ReadonlySet<string>;
44
+ /**
45
+ * Whether a field definition's type materialises no stored column.
46
+ *
47
+ * Shaped like {@link isExpandableFieldType}: only `type` is read, so it answers
48
+ * for any field-metadata shape an object schema resolves to, and an unreadable
49
+ * definition is not a verdict — `null` / `undefined` / a bare type string all
50
+ * answer `false`.
51
+ */
52
+ export declare function isUnmaterializedFieldType(fieldDef: unknown): boolean;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * ObjectUI — unmaterialized-fields utility
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
+ import { SEARCH_VIRTUAL_TYPES } from '@objectstack/spec/data';
9
+ /**
10
+ * Field types no driver materialises a stored column for — the value is
11
+ * computed on read (objectui#3950).
12
+ *
13
+ * A STORAGE fact, not a taste judgement, and it decides what the SERVER can be
14
+ * asked to do with such a field. All three query axes have now closed on it:
15
+ *
16
+ * - SORT — refused since objectstack#6994 (`400 INVALID_SORT`). Before that it
17
+ * degraded in silence: the response carried the very values it was asked to
18
+ * order by, out of order, under a `200`, with ascending and descending
19
+ * byte-identical (measured on a real SQL driver).
20
+ * - FILTER — refused since objectstack#8296 (`400 INVALID_FIELD`); previously
21
+ * `200` with zero rows whichever way the predicate pointed.
22
+ * - SEARCH — refused by name since objectstack#6674.
23
+ *
24
+ * The membership is bound to `@objectstack/spec` instead of being written out
25
+ * here, on the reasoning the platform's own filter door applies to itself: a
26
+ * layer that re-decided "which types have a column" drifts from the drivers,
27
+ * and then either withholds an affordance that works or offers one that 400s.
28
+ * The spec pins the set to `formula` alone.
29
+ *
30
+ * Deliberately NOT `COMPUTED_VALUE_TYPES` (`formula` / `summary` /
31
+ * `autonumber`) — that is the WRITE contract ("never client-written"). A
32
+ * `summary` and an `autonumber` each get a real maintained column and order,
33
+ * filter and search correctly, so widening to that set would withhold
34
+ * affordances from two types that work. The platform's own conformance suite
35
+ * pins that trap by name.
36
+ *
37
+ * SCOPE on the sort axis: this rules out a sort the SERVER performs. A
38
+ * client-side sort keys off the value the cell renders — which the server
39
+ * hydrates on read — so it orders by exactly what the user sees and stays
40
+ * honest. Consumers therefore apply this where they already apply the
41
+ * relational carve-out (see {@link EXPANDABLE_FIELD_TYPES}): under server
42
+ * sorting only.
43
+ */
44
+ export const UNMATERIALIZED_FIELD_TYPES = SEARCH_VIRTUAL_TYPES;
45
+ /**
46
+ * Whether a field definition's type materialises no stored column.
47
+ *
48
+ * Shaped like {@link isExpandableFieldType}: only `type` is read, so it answers
49
+ * for any field-metadata shape an object schema resolves to, and an unreadable
50
+ * definition is not a verdict — `null` / `undefined` / a bare type string all
51
+ * answer `false`.
52
+ */
53
+ export function isUnmaterializedFieldType(fieldDef) {
54
+ return (!!fieldDef &&
55
+ typeof fieldDef === 'object' &&
56
+ UNMATERIALIZED_FIELD_TYPES.has(fieldDef.type));
57
+ }
@@ -13,6 +13,7 @@
13
13
  * is client-side form validation, not a mirror of a server rule.
14
14
  */
15
15
  export * from './required-presence.js';
16
+ export * from './server-owned-value.js';
16
17
  export * from './validation-engine.js';
17
18
  export * from './schema-validator.js';
18
19
  export * from './validators/index.js';
@@ -13,6 +13,7 @@
13
13
  * is client-side form validation, not a mirror of a server rule.
14
14
  */
15
15
  export * from './required-presence.js';
16
+ export * from './server-owned-value.js';
16
17
  export * from './validation-engine.js';
17
18
  export * from './schema-validator.js';
18
19
  export * from './validators/index.js';
@@ -123,6 +123,105 @@ function validateCRUDSchema(schema, path = 'schema') {
123
123
  }
124
124
  return errors;
125
125
  }
126
+ /**
127
+ * The ONE namespace a form field's widget id may name — objectui#5254,
128
+ * maintainer ruling of 2026-08-19.
129
+ *
130
+ * A form field's `type` either names a `field:`-namespaced widget or takes the
131
+ * renderer's declared `default` input branch. It never resolves a plain SDUI
132
+ * node renderer: `renderFieldComponent` (`@object-ui/components`) resolves a
133
+ * colon-qualified id ONLY under this prefix, and `resolveFieldLabelling` /
134
+ * `resolvesToRegisteredFieldWidget` there mirror it exactly.
135
+ */
136
+ const FIELD_WIDGET_NAMESPACE = 'field:';
137
+ /**
138
+ * The widget id a form field will actually be RENDERED as.
139
+ *
140
+ * Mirrors the renderer's own precedence (`resolvedType` in
141
+ * `packages/components/src/renderers/form/form.tsx`: explicit form-config
142
+ * `widget`, then the resolved field metadata's widget hint, then the bare
143
+ * `type`). Only the two keys an AUTHORED schema carries are read here —
144
+ * `field.field?.widget` is a resolved metadata OBJECT that a hand-authored
145
+ * standalone `FormSchema` does not have.
146
+ *
147
+ * The mirroring is the point, not a convenience: validator and renderer must
148
+ * not be able to disagree about which component will actually render, which is
149
+ * the same discipline `resolveFieldLabelling` states on the renderer side.
150
+ */
151
+ function resolveAuthoredFieldWidgetId(field) {
152
+ const id = field.widget ?? field.type;
153
+ return typeof id === 'string' ? id : undefined;
154
+ }
155
+ /**
156
+ * A form field's widget id names a namespace that resolves NO field widget —
157
+ * objectui#5375, maintainer ruling of 2026-08-20 (option C, the primary).
158
+ *
159
+ * ## What this refuses, and why it is an error rather than a warning
160
+ *
161
+ * Measured on `main` at f2e11ae6f, the real `form` renderer on the built-in
162
+ * path (no `registerAllFields()`):
163
+ *
164
+ * type registry hit rendered type
165
+ * ui:password TRUE text
166
+ * secret false text
167
+ * field:secret false text
168
+ *
169
+ * `ui:password` **is** registered — as an SDUI node renderer for a top-level
170
+ * `{ type: 'email' }`-style node — so an author who checks whether it resolves
171
+ * gets a YES, and still gets a clear-text box on the field path. That is the
172
+ * shape where verifying does not protect you, and it is why the class, not
173
+ * another entry in a renderer-side table, is what gets closed here: the table
174
+ * fix answers today's spellings and leaves the next invented id to find the
175
+ * same silent degrade. AI-authored metadata invents plausible-looking widget
176
+ * ids constantly; this makes inventing one an ERROR at authoring time instead
177
+ * of a text box that looks like it worked.
178
+ *
179
+ * A warning was explicitly ruled out — a warning IS the silent degrade this
180
+ * check exists to kill, in a new spelling.
181
+ *
182
+ * ## Why only the NAMESPACED spelling
183
+ *
184
+ * `field:` is allowed whether or not the widget is registered: registration is
185
+ * a RUNTIME fact (`registerAllFields()`, a lazily loaded plugin) that an
186
+ * authoring-time validator cannot see, and the renderer already answers an
187
+ * unregistered `field:` secret with a visible refusal (objectui#5322). A BARE
188
+ * name is likewise allowed: it is an open set — every registered `field:<name>`
189
+ * widget, third-party ones included, is reachable by its short name.
190
+ *
191
+ * What is decidable statically is the namespace, and it is a CLOSED set of one.
192
+ * Any other `<ns>:<name>` resolves nothing on the field path by the #5254
193
+ * ruling and falls to the renderer's plain-text `default` arm.
194
+ *
195
+ * ## Census (the ruling made arming this conditional on one)
196
+ *
197
+ * 4,397 authored files in this repo, 649 form-field `type`/`widget` literals,
198
+ * 98 structurally parsed form-field entries in `examples/schema-catalog`:
199
+ * SEVEN colon-qualified ids on the form-field path, five of them `field:*`.
200
+ * The two remaining occurrences are one source line naming `ref:component`,
201
+ * which is a metadata-admin designer `SchemaForm` widget resolved by its own
202
+ * `WIDGETS` table — a different shape (`{ field, widget }` under
203
+ * `sections[].fields[]`, node type `simple`) that this function, gated on
204
+ * `type === 'form'`, never reaches. Zero occurrences of `ui:password`,
205
+ * `secret` or `field:secret` anywhere on the field path.
206
+ */
207
+ function validateFieldWidgetNamespace(field, path) {
208
+ const id = resolveAuthoredFieldWidgetId(field);
209
+ if (!id || !id.includes(':') || id.startsWith(FIELD_WIDGET_NAMESPACE))
210
+ return undefined;
211
+ const key = typeof field.widget === 'string' ? 'widget' : 'type';
212
+ return {
213
+ path: `${path}.${key}`,
214
+ message: `'${id}' is not a form field widget: a namespaced field widget id must name the ` +
215
+ `\`field:\` namespace (objectui#5254), so this id resolves NOTHING on the field path ` +
216
+ `and the renderer would fall through to a plain text box — putting a secret on screen ` +
217
+ `in clear text when the field holds one (objectui#5375). Write the built-in spelling ` +
218
+ `(e.g. \`password\`), or the field widget id \`field:${id.slice(id.indexOf(':') + 1)}\`. ` +
219
+ `Resolving in the registry is not proof it resolves HERE — \`ui:password\` is a ` +
220
+ `registered SDUI node renderer and still renders clear text as a field.`,
221
+ type: 'error',
222
+ code: 'UNRESOLVABLE_FIELD_WIDGET_NAMESPACE',
223
+ };
224
+ }
126
225
  /**
127
226
  * Validate form schema specific properties
128
227
  */
@@ -139,6 +238,12 @@ function validateFormSchema(schema, path = 'schema') {
139
238
  code: 'MISSING_FIELD_NAME'
140
239
  });
141
240
  }
241
+ // objectui#5375 — an invented namespaced widget id is an ERROR here,
242
+ // not a silent clear-text box at render time. See the helper's doc.
243
+ const namespaceError = validateFieldWidgetNamespace(field, `${path}.fields[${index}]`);
244
+ if (namespaceError) {
245
+ errors.push(namespaceError);
246
+ }
142
247
  // Check for duplicate field names
143
248
  const duplicates = schema.fields.filter((f) => f.name === field.name);
144
249
  if (duplicates.length > 1) {