@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
@@ -9,11 +9,23 @@
9
9
  * The grouping policy, alone and testable: does this number get thousands
10
10
  * separators?
11
11
  *
12
- * @param scale the field's declared `scale`, or `undefined` when the caller
13
- * has no field declaration behind it
14
- * @param currency ISO 4217 code when the number is money
12
+ * This is the ONE place the decision is made. An authored `useGrouping`
13
+ * answers it outright; only when the field declared nothing does the
14
+ * `scale`/`currency` heuristic below get a say (objectui#11026).
15
+ *
16
+ * @param scale the field's declared `scale`, or `undefined` when the
17
+ * caller has no field declaration behind it
18
+ * @param currency ISO 4217 code when the number is money
19
+ * @param useGrouping the field's authored `useGrouping`, or `undefined` when
20
+ * the author declared none
15
21
  */
16
- export function shouldGroupDisplayNumber(scale, currency) {
22
+ export function shouldGroupDisplayNumber(scale, currency, useGrouping) {
23
+ // The author's explicit declaration beats every guess below, money included:
24
+ // the spec reads `false` as "never grouped, regardless of what the
25
+ // renderer's heuristic would have guessed", and `true` as the same override
26
+ // in the other direction.
27
+ if (typeof useGrouping === 'boolean')
28
+ return useGrouping;
17
29
  // Money always groups — including money whose currency code could not be
18
30
  // resolved, which still renders as an amount (just without a symbol).
19
31
  if (currency)
@@ -32,7 +44,7 @@ export function shouldGroupDisplayNumber(scale, currency) {
32
44
  * malformed tag from a tenant config must never take a grid cell down.
33
45
  */
34
46
  export function formatDisplayNumber(value, options = {}) {
35
- const { locale, currency, scale, style, ...passthrough } = options;
47
+ const { locale, currency, scale, useGrouping, style, ...passthrough } = options;
36
48
  const intlOptions = { ...passthrough };
37
49
  if (style === 'percentPoints') {
38
50
  // The locale's percent convention WITHOUT `style: 'percent'`'s ×100 — see
@@ -50,15 +62,25 @@ export function formatDisplayNumber(value, options = {}) {
50
62
  delete intlOptions.unit;
51
63
  delete intlOptions.unitDisplay;
52
64
  }
53
- // ⚠️ Set `useGrouping` ONLY to suppress. `useGrouping: true` is NOT the same
54
- // as omitting the key: `true` means "always", while omitting it means "auto"
55
- // (and "min2" under compact notation), which is the locale's own preference.
56
- // Measured — for 1234: es-ES "auto" → `1234` but "always" → `1.234`; pl-PL
57
- // "auto" → `1234` but "always" → `1 234`. Writing `true` here would silently
58
- // override those locales' conventions in the name of preserving en-US output.
59
- if (!shouldGroupDisplayNumber(scale, currency)) {
65
+ // ⚠️ Never write `useGrouping: true` on the renderer's own behalf.
66
+ // `useGrouping: true` is NOT the same as omitting the key: `true` means
67
+ // "always", while omitting it means "auto" (and "min2" under compact
68
+ // notation), which is the locale's own preference. Measured — for 1234:
69
+ // es-ES "auto" → `1234` but "always" → `1.234`; pl-PL "auto" → `1234` but
70
+ // "always" → `1 234`. Writing `true` for a field that declared nothing would
71
+ // silently override those locales' conventions in the name of preserving
72
+ // en-US output.
73
+ //
74
+ // The ONE `true` this function writes is the author's own (objectui#11026):
75
+ // `FieldSchema.useGrouping` maps 1:1 onto this `Intl` option, and an author
76
+ // who pinned grouping on asked for "always". The decision itself is made
77
+ // once, above, by `shouldGroupDisplayNumber`; this only spells it for `Intl`.
78
+ if (!shouldGroupDisplayNumber(scale, currency, useGrouping)) {
60
79
  intlOptions.useGrouping = false;
61
80
  }
81
+ else if (useGrouping === true) {
82
+ intlOptions.useGrouping = true;
83
+ }
62
84
  try {
63
85
  return new Intl.NumberFormat(locale, intlOptions).format(value);
64
86
  }
@@ -0,0 +1,113 @@
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
+ * object-aggregate-query — the ONE answer to "which CALL does an object-bound
10
+ * `aggregate` make when its authored `groupBy` is the structured node?"
11
+ * (objectui#8613).
12
+ *
13
+ * ## The two wires, and why the choice is not cosmetic
14
+ *
15
+ * An authored `aggregate.groupBy` is a union: a bare field name, or the
16
+ * structured date-bucketing node `{ field, dateGranularity?, alias? }` — the
17
+ * union `ChartGroupBySchema` declares and `GroupByNodeSchema` (the query AST)
18
+ * mirrors. The adapter routes on the SHAPE of the params it is handed, not on
19
+ * the shape of the authored value:
20
+ *
21
+ * - `{ groupBy: GroupByNode[], aggregations, where }` — the spec-shape query,
22
+ * which reaches `engine.aggregate` and runs the server-side date-bucket
23
+ * engine;
24
+ * - `{ field, function, groupBy, filter }` — the legacy cube/analytics query,
25
+ * whose `dimensions` the contract declares as an array of dimension NAMES
26
+ * and which does NOT honour `dateGranularity` at all.
27
+ *
28
+ * So a structured node forwarded on the legacy wire is posted as a dimension
29
+ * OBJECT where a name is declared: the author asks for monthly buckets and the
30
+ * platform answers a different question, with no diagnostic on the way. That is
31
+ * the defect objectui#8613 records on the metric path, and it was reachable
32
+ * there only because this payload was spelled INSIDE one renderer
33
+ * (`ObjectChart.runAggregate`) rather than anywhere a second renderer could
34
+ * reuse it.
35
+ *
36
+ * ## Why a shared module rather than a second copy
37
+ *
38
+ * The alternative was to transcribe the eight lines into the metric widget.
39
+ * That is the objectui#5042 / #7544 / #8193 / #8266 / #8269 drift shape — a
40
+ * second opinion of one question, with nothing keeping the two in agreement —
41
+ * and it is the same shape those cards' fixes (`chartMeasureKey`,
42
+ * `chartCategoryKey`) exist to end, one column at a time. This module is that
43
+ * move applied to the REQUEST rather than to a result column.
44
+ *
45
+ * ## What is NOT here
46
+ *
47
+ * The legacy bag itself. The two renderers do not agree on it and should not be
48
+ * made to: a chart passes the authored `groupBy` through untouched, while a
49
+ * metric floors an absent one at `'_all'` (one bucket) because it paints a
50
+ * single number. Folding that difference into a shared builder would put a
51
+ * renderer's own default into a module that has no business holding one, so the
52
+ * shared part stops exactly where the agreement stops.
53
+ */
54
+ import type { ChartGroupBy } from '@objectstack/spec/ui';
55
+ import { type ChartAggregateLike } from './chart-measure-key.js';
56
+ /**
57
+ * The STRUCTURED arm of the contract's `groupBy` union — the date-bucketing
58
+ * node, as distinct from the bare field name the same union admits.
59
+ *
60
+ * Extracted from `ChartGroupBy` rather than restated, so a member added to the
61
+ * node upstream (as `alias` once was) arrives here without an edit.
62
+ */
63
+ export type StructuredGroupBy = Extract<ChartGroupBy, {
64
+ field: string;
65
+ }>;
66
+ /**
67
+ * The spec-shape aggregate query's `aggregations[]` entry, as
68
+ * `AggregationNodeSchema` declares it: `alias` is REQUIRED (it names the column
69
+ * the measure is projected under), `field` is optional because a `count` counts
70
+ * rows rather than a column.
71
+ */
72
+ export interface ObjectAggregationNode {
73
+ function?: string;
74
+ alias: string;
75
+ field?: string;
76
+ }
77
+ /** The spec-shape query body — `{ groupBy: GroupByNode[], aggregations, where }`. */
78
+ export interface ObjectAggregateSpecQuery {
79
+ groupBy: StructuredGroupBy[];
80
+ aggregations: ObjectAggregationNode[];
81
+ where: unknown;
82
+ }
83
+ /**
84
+ * Is this authored `aggregate.groupBy` the structured node rather than a bare
85
+ * field name?
86
+ *
87
+ * ⚠️ An ARRAY answers `false`. It is an object, but it is not this union's
88
+ * object arm — neither `ChartGroupBySchema` nor `ObjectMetricPropsSchema`'s
89
+ * `aggregate` admits one at this position, and `data-objectstack`'s adapter
90
+ * already refuses it at the wire (`503cd8b89`). Letting it through here would
91
+ * wrap it into `groupBy: [[…]]` and turn a producer-side refusal into a
92
+ * malformed query.
93
+ */
94
+ export declare function isStructuredGroupBy(groupBy: unknown): groupBy is StructuredGroupBy;
95
+ /**
96
+ * The spec-shape aggregate query for an object-bound `aggregate` whose
97
+ * `groupBy` is the structured node.
98
+ *
99
+ * The measure is projected under {@link chartMeasureKey}'s answer — the raw
100
+ * field name, or the literal `'count'` for a fieldless count — so a caller's
101
+ * existing result readback finds the value under the key it already looks for,
102
+ * and a chart's series binding names the column the rows actually carry.
103
+ *
104
+ * `field` is omitted for `count` so the engine emits `count(*)`: the dashboard
105
+ * relays default `field: 'value'` for a widget with no explicit measure column,
106
+ * and `count(value)` crashes SQL drivers with "no such column: value" on the
107
+ * count-the-rows case that is the common one.
108
+ *
109
+ * @param aggregate the authored inline aggregate
110
+ * @param groupBy its `groupBy`, already narrowed by {@link isStructuredGroupBy}
111
+ * @param where the resolved filter, in the spec Query DSL's `where` position
112
+ */
113
+ export declare function objectAggregateSpecQuery(aggregate: ChartAggregateLike, groupBy: StructuredGroupBy, where: unknown): ObjectAggregateSpecQuery;
@@ -0,0 +1,50 @@
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
+ import { chartMeasureKey } from './chart-measure-key.js';
9
+ /**
10
+ * Is this authored `aggregate.groupBy` the structured node rather than a bare
11
+ * field name?
12
+ *
13
+ * ⚠️ An ARRAY answers `false`. It is an object, but it is not this union's
14
+ * object arm — neither `ChartGroupBySchema` nor `ObjectMetricPropsSchema`'s
15
+ * `aggregate` admits one at this position, and `data-objectstack`'s adapter
16
+ * already refuses it at the wire (`503cd8b89`). Letting it through here would
17
+ * wrap it into `groupBy: [[…]]` and turn a producer-side refusal into a
18
+ * malformed query.
19
+ */
20
+ export function isStructuredGroupBy(groupBy) {
21
+ return !!groupBy && typeof groupBy === 'object' && !Array.isArray(groupBy);
22
+ }
23
+ /**
24
+ * The spec-shape aggregate query for an object-bound `aggregate` whose
25
+ * `groupBy` is the structured node.
26
+ *
27
+ * The measure is projected under {@link chartMeasureKey}'s answer — the raw
28
+ * field name, or the literal `'count'` for a fieldless count — so a caller's
29
+ * existing result readback finds the value under the key it already looks for,
30
+ * and a chart's series binding names the column the rows actually carry.
31
+ *
32
+ * `field` is omitted for `count` so the engine emits `count(*)`: the dashboard
33
+ * relays default `field: 'value'` for a widget with no explicit measure column,
34
+ * and `count(value)` crashes SQL drivers with "no such column: value" on the
35
+ * count-the-rows case that is the common one.
36
+ *
37
+ * @param aggregate the authored inline aggregate
38
+ * @param groupBy its `groupBy`, already narrowed by {@link isStructuredGroupBy}
39
+ * @param where the resolved filter, in the spec Query DSL's `where` position
40
+ */
41
+ export function objectAggregateSpecQuery(aggregate, groupBy, where) {
42
+ const aggFn = aggregate.function;
43
+ const node = {
44
+ function: aggFn,
45
+ alias: chartMeasureKey(aggregate, aggFn || 'count'),
46
+ };
47
+ if (aggFn !== 'count' && aggregate.field)
48
+ node.field = aggregate.field;
49
+ return { groupBy: [groupBy], aggregations: [node], where };
50
+ }
@@ -0,0 +1,123 @@
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 parent-relationship condition a detail-page related list is scoped by —
9
+ * compiled ONCE, here, for every surface that asks the question.
10
+ *
11
+ * ## Why this is a seam and not a helper
12
+ *
13
+ * A related list asks its backend "which children belong to this parent?" from
14
+ * two places: the ROW query (`RelatedList`) and the tab-badge count probe
15
+ * (`RelatedCountStore`). Those two used to compile the condition separately,
16
+ * and objectui#8882 is what that costs: objectui#7299 taught the ROW side to
17
+ * compile by the relationship field's ARITY, the BADGE side kept sending bare
18
+ * equality, and on a multi-value relationship the page then rendered rows above
19
+ * a tab with no count at all — two implementations of one question, drifted.
20
+ *
21
+ * Patching the second copy to match the first would leave two copies. So the
22
+ * condition has one implementation, and both callers import it.
23
+ *
24
+ * ## The rule itself is not invented here
25
+ *
26
+ * The arity verdict is `@objectstack/spec/data`'s own `isMultiValueField`. That
27
+ * matters more here than anywhere: this function chooses `$contains` vs `=` and
28
+ * every surface that asks the question now reads that one choice, so two
29
+ * readers of one question disagreeing — the entire defect class — cannot
30
+ * recur INSIDE this repository. The spec's rule is BROADER than an eyeballed
31
+ * `multiple === true` in both directions — `multiselect` / `checkboxes` /
32
+ * `tags` persist an array with no flag at all, and `multiple: true` is INERT on
33
+ * a type outside the spec's multi-capable set (`master_detail`, say) — so a
34
+ * local approximation is wrong in both directions, not merely incomplete.
35
+ *
36
+ * ⛔ Do not add a local arity rule at any call site, however small, and ⛔ do
37
+ * not widen this function to accept an arity the caller computed: the parameter
38
+ * it takes is METADATA, and the verdict is drawn from it here.
39
+ *
40
+ * ## ⚠️ The STORAGE side does not read this predicate — the two rules DIVERGE
41
+ *
42
+ * This header used to say `isMultiValueField` is "the same predicate the driver
43
+ * that executes the query decides on". It is not, and the difference is
44
+ * observable (objectui#8937). Measured on objectstack `origin/main`,
45
+ * `driver-sql` gates the equality family on its own STORAGE question,
46
+ * `isJsonField`: a column is JSON when the field's type is in that driver's
47
+ * `JSON_COLUMN_TYPES` — the spec's `STRUCTURED_JSON_TYPES` and
48
+ * `MULTI_OPTION_TYPES`, plus the driver-internal `object` / `array` aliases —
49
+ * OR when `multiple` is merely TRUTHY, on ANY type; a single-value media type
50
+ * answers from the ADR-0104 dual-encoding window instead. The spec's predicate
51
+ * is `MULTI_OPTION_TYPES.has(type) || (MULTI_CAPABLE_TYPES.has(type) &&
52
+ * multiple === true)`.
53
+ *
54
+ * ⇒ They diverge for a type OUTSIDE `MULTI_CAPABLE_TYPES` carrying
55
+ * `multiple: true` (`master_detail` / `tree` / `text`): the spec says
56
+ * single-valued, so this seam compiles `=`, while the driver stores a JSON
57
+ * column and refuses `=` with the same `400 INVALID_FILTER` objectui#7299 was
58
+ * filed for. That is a KNOWN divergence and ⛔ not a regression — both
59
+ * surfaces sent `=` for that shape before objectui#7299 and objectui#8882 too.
60
+ *
61
+ * ⛔ Do NOT close it by widening the predicate here: which of the two rules is
62
+ * right is an upstream question, filed as objectstack#17469 (enforce-or-align),
63
+ * and this seam is deliberately not blocked on it. Widening on this side would
64
+ * move the disagreement across the wire rather than end it, and would make the
65
+ * ONE compiler this module exists to be disagree with the spec it delegates to.
66
+ * The divergence is re-derived from the installed spec on every run by
67
+ * `relatedListParentScopeResidue-8937.test.ts`, so this section reddens when the
68
+ * spec side moves.
69
+ */
70
+ import { type ValueShapeFieldDef } from '@objectstack/spec/data';
71
+ import type { FieldContainerLike } from './predicate-record.js';
72
+ /**
73
+ * Look one field def up in either container shape the metadata API serves.
74
+ *
75
+ * The pair is the one {@link FieldContainerLike} names: the Record keyed by
76
+ * field name, and the array of defs carrying their own `name`. A reader that
77
+ * knows only one of them silently answers "no such field" for the other — and
78
+ * "no such field" here means "single-valued", which is this card's own bug
79
+ * spelled as a default.
80
+ */
81
+ export declare function parentRelationshipFieldDef(fields: FieldContainerLike, fieldName: string | undefined): ValueShapeFieldDef | undefined;
82
+ /**
83
+ * Does this relationship field store MANY parent ids rather than one?
84
+ *
85
+ * The seam's verdict, exposed on its own because a caller sometimes needs the
86
+ * ANSWER without the condition — the raw-URL related-list path, whose
87
+ * `filter[<field>]=<value>` grammar has no membership operator, has to know
88
+ * the arity in order to REFUSE. Deriving it a second time at that call site is
89
+ * how the two compilers this module exists to merge came about, so it is
90
+ * derived once here and read from both shapes below.
91
+ */
92
+ export declare function isMultiValueRelationship(fields: FieldContainerLike, fieldName: string | undefined): boolean;
93
+ /**
94
+ * The parent-relationship condition, compiled to match the field's ARITY.
95
+ *
96
+ * - single-valued → `{ [relationshipField]: parentId }` — equality, byte for
97
+ * byte what both surfaces have always sent;
98
+ * - `multiple: true` (per the spec predicate) →
99
+ * `{ [relationshipField]: { $contains: parentId } }` — MEMBERSHIP, because
100
+ * the stored value is an ARRAY of ids and equality asks whether that whole
101
+ * array IS one id. `$contains` is the spelling the drivers execute for it,
102
+ * the one `driver-sql` names in the `400 INVALID_FILTER` it answers the
103
+ * equality form with.
104
+ *
105
+ * The author never writes either: they named a relationship, and its storage
106
+ * form is the renderer's business.
107
+ *
108
+ * The return value is the plain MongoDB-style object both call sites have
109
+ * always put on the wire, NOT a lowered ObjectQL AST. That is deliberate: with
110
+ * nothing else declared the query must stay byte-identical to what it was, and
111
+ * a freshly lowered AST would mean the same thing, be invisible on screen, and
112
+ * break every caller pinning the wire. Composition with a list's own declared
113
+ * scope stays the caller's step, through `mergeFilterNodes`.
114
+ *
115
+ * @param relationshipField The child field pointing back at the parent.
116
+ * @param parentId The parent record's primary key value.
117
+ * @param fields The CHILD object's field defs (`objectSchema.fields`),
118
+ * in either served shape. Without them the condition
119
+ * compiles to equality — the historical wire, and the
120
+ * only answer available to a caller that cannot see
121
+ * the metadata. A caller that CAN see it must pass it.
122
+ */
123
+ export declare function composeParentScopeFilter(relationshipField: string, parentId: string | number, fields?: FieldContainerLike): Record<string, unknown>;
@@ -0,0 +1,140 @@
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 parent-relationship condition a detail-page related list is scoped by —
9
+ * compiled ONCE, here, for every surface that asks the question.
10
+ *
11
+ * ## Why this is a seam and not a helper
12
+ *
13
+ * A related list asks its backend "which children belong to this parent?" from
14
+ * two places: the ROW query (`RelatedList`) and the tab-badge count probe
15
+ * (`RelatedCountStore`). Those two used to compile the condition separately,
16
+ * and objectui#8882 is what that costs: objectui#7299 taught the ROW side to
17
+ * compile by the relationship field's ARITY, the BADGE side kept sending bare
18
+ * equality, and on a multi-value relationship the page then rendered rows above
19
+ * a tab with no count at all — two implementations of one question, drifted.
20
+ *
21
+ * Patching the second copy to match the first would leave two copies. So the
22
+ * condition has one implementation, and both callers import it.
23
+ *
24
+ * ## The rule itself is not invented here
25
+ *
26
+ * The arity verdict is `@objectstack/spec/data`'s own `isMultiValueField`. That
27
+ * matters more here than anywhere: this function chooses `$contains` vs `=` and
28
+ * every surface that asks the question now reads that one choice, so two
29
+ * readers of one question disagreeing — the entire defect class — cannot
30
+ * recur INSIDE this repository. The spec's rule is BROADER than an eyeballed
31
+ * `multiple === true` in both directions — `multiselect` / `checkboxes` /
32
+ * `tags` persist an array with no flag at all, and `multiple: true` is INERT on
33
+ * a type outside the spec's multi-capable set (`master_detail`, say) — so a
34
+ * local approximation is wrong in both directions, not merely incomplete.
35
+ *
36
+ * ⛔ Do not add a local arity rule at any call site, however small, and ⛔ do
37
+ * not widen this function to accept an arity the caller computed: the parameter
38
+ * it takes is METADATA, and the verdict is drawn from it here.
39
+ *
40
+ * ## ⚠️ The STORAGE side does not read this predicate — the two rules DIVERGE
41
+ *
42
+ * This header used to say `isMultiValueField` is "the same predicate the driver
43
+ * that executes the query decides on". It is not, and the difference is
44
+ * observable (objectui#8937). Measured on objectstack `origin/main`,
45
+ * `driver-sql` gates the equality family on its own STORAGE question,
46
+ * `isJsonField`: a column is JSON when the field's type is in that driver's
47
+ * `JSON_COLUMN_TYPES` — the spec's `STRUCTURED_JSON_TYPES` and
48
+ * `MULTI_OPTION_TYPES`, plus the driver-internal `object` / `array` aliases —
49
+ * OR when `multiple` is merely TRUTHY, on ANY type; a single-value media type
50
+ * answers from the ADR-0104 dual-encoding window instead. The spec's predicate
51
+ * is `MULTI_OPTION_TYPES.has(type) || (MULTI_CAPABLE_TYPES.has(type) &&
52
+ * multiple === true)`.
53
+ *
54
+ * ⇒ They diverge for a type OUTSIDE `MULTI_CAPABLE_TYPES` carrying
55
+ * `multiple: true` (`master_detail` / `tree` / `text`): the spec says
56
+ * single-valued, so this seam compiles `=`, while the driver stores a JSON
57
+ * column and refuses `=` with the same `400 INVALID_FILTER` objectui#7299 was
58
+ * filed for. That is a KNOWN divergence and ⛔ not a regression — both
59
+ * surfaces sent `=` for that shape before objectui#7299 and objectui#8882 too.
60
+ *
61
+ * ⛔ Do NOT close it by widening the predicate here: which of the two rules is
62
+ * right is an upstream question, filed as objectstack#17469 (enforce-or-align),
63
+ * and this seam is deliberately not blocked on it. Widening on this side would
64
+ * move the disagreement across the wire rather than end it, and would make the
65
+ * ONE compiler this module exists to be disagree with the spec it delegates to.
66
+ * The divergence is re-derived from the installed spec on every run by
67
+ * `relatedListParentScopeResidue-8937.test.ts`, so this section reddens when the
68
+ * spec side moves.
69
+ */
70
+ import { isMultiValueField } from '@objectstack/spec/data';
71
+ /**
72
+ * Look one field def up in either container shape the metadata API serves.
73
+ *
74
+ * The pair is the one {@link FieldContainerLike} names: the Record keyed by
75
+ * field name, and the array of defs carrying their own `name`. A reader that
76
+ * knows only one of them silently answers "no such field" for the other — and
77
+ * "no such field" here means "single-valued", which is this card's own bug
78
+ * spelled as a default.
79
+ */
80
+ export function parentRelationshipFieldDef(fields, fieldName) {
81
+ if (!fieldName || !fields || typeof fields !== 'object')
82
+ return undefined;
83
+ const def = Array.isArray(fields)
84
+ ? fields.find((f) => f?.name === fieldName)
85
+ : fields[fieldName];
86
+ if (!def || typeof def !== 'object')
87
+ return undefined;
88
+ // `type` is the one member the spec's predicate reads besides `multiple`; a
89
+ // def without it answers `false` through both of the predicate's set lookups,
90
+ // which is the right answer for a field whose type nobody declared.
91
+ return def;
92
+ }
93
+ /**
94
+ * Does this relationship field store MANY parent ids rather than one?
95
+ *
96
+ * The seam's verdict, exposed on its own because a caller sometimes needs the
97
+ * ANSWER without the condition — the raw-URL related-list path, whose
98
+ * `filter[<field>]=<value>` grammar has no membership operator, has to know
99
+ * the arity in order to REFUSE. Deriving it a second time at that call site is
100
+ * how the two compilers this module exists to merge came about, so it is
101
+ * derived once here and read from both shapes below.
102
+ */
103
+ export function isMultiValueRelationship(fields, fieldName) {
104
+ const def = parentRelationshipFieldDef(fields, fieldName);
105
+ return def !== undefined && isMultiValueField(def);
106
+ }
107
+ /**
108
+ * The parent-relationship condition, compiled to match the field's ARITY.
109
+ *
110
+ * - single-valued → `{ [relationshipField]: parentId }` — equality, byte for
111
+ * byte what both surfaces have always sent;
112
+ * - `multiple: true` (per the spec predicate) →
113
+ * `{ [relationshipField]: { $contains: parentId } }` — MEMBERSHIP, because
114
+ * the stored value is an ARRAY of ids and equality asks whether that whole
115
+ * array IS one id. `$contains` is the spelling the drivers execute for it,
116
+ * the one `driver-sql` names in the `400 INVALID_FILTER` it answers the
117
+ * equality form with.
118
+ *
119
+ * The author never writes either: they named a relationship, and its storage
120
+ * form is the renderer's business.
121
+ *
122
+ * The return value is the plain MongoDB-style object both call sites have
123
+ * always put on the wire, NOT a lowered ObjectQL AST. That is deliberate: with
124
+ * nothing else declared the query must stay byte-identical to what it was, and
125
+ * a freshly lowered AST would mean the same thing, be invisible on screen, and
126
+ * break every caller pinning the wire. Composition with a list's own declared
127
+ * scope stays the caller's step, through `mergeFilterNodes`.
128
+ *
129
+ * @param relationshipField The child field pointing back at the parent.
130
+ * @param parentId The parent record's primary key value.
131
+ * @param fields The CHILD object's field defs (`objectSchema.fields`),
132
+ * in either served shape. Without them the condition
133
+ * compiles to equality — the historical wire, and the
134
+ * only answer available to a caller that cannot see
135
+ * the metadata. A caller that CAN see it must pass it.
136
+ */
137
+ export function composeParentScopeFilter(relationshipField, parentId, fields) {
138
+ const multi = isMultiValueRelationship(fields, relationshipField);
139
+ return { [relationshipField]: multi ? { $contains: parentId } : parentId };
140
+ }
@@ -30,6 +30,15 @@
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: `recordIdField`, since objectui#10277 the field a
36
+ * `defaultFromRow` param seeds from and the `{field}` tokens of an action's
37
+ * `target`, and since objectui#10404 the fields an `undoable` action writes,
38
+ * whose prior values the Undo capture reads off the row. The name kept the
39
+ * "predicate" spelling because the mechanism is identical (harvest a name, gate
40
+ * it against the declared fields, add it to `$select`); see
41
+ * {@link listViewPredicates} for what is in the set and why.
33
42
  */
34
43
  /**
35
44
  * The columns the framework provisions on EVERY business object
@@ -71,13 +80,62 @@ export declare function isProjectableField(name: string, declaredFields: Record<
71
80
  */
72
81
  export declare function collectPredicateFieldRefs(predicates: readonly unknown[]): string[];
73
82
  /**
74
- * Every predicate a list-ish view carries, flattened for
83
+ * Every record-field reference a list-ish view carries, flattened for
75
84
  * {@link collectPredicateFieldRefs}.
76
85
  *
77
86
  * 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.
87
+ * is a place a row field is actually READ — conditional formatting, the row
88
+ * kebab (custom defs AND the built-in Edit/Delete overrides), the selection bar,
89
+ * and the object's declared actions.
90
+ *
91
+ * Most entries are predicates. Five are not, and all are spelled as a synthetic
92
+ * `record.<name>` so the one harvester handles them: conditional formatting's
93
+ * native `{ field, operator, value }` shape, an action's `recordIdField`
94
+ * (objectstack#8018), the field each `defaultFromRow` param seeds from, each
95
+ * `{field}` token of the action's `target` (both objectui#10277), and the fields
96
+ * an `undoable` action writes (objectui#10404). The
97
+ * `recordIdField` case is the same *class* of bug the predicate harvest exists
98
+ * to close, one surface over — the action runtime reads
99
+ * `rowRecord[action.recordIdField]` to seed `recordIdParam`, so a key outside the
100
+ * listView columns arrived absent and the injection was silently skipped, which
101
+ * turns a record-scoped mutation into one that names no record while still
102
+ * reporting success. The default (`id`) is already projected unconditionally, so
103
+ * only an explicit declaration adds anything here.
104
+ *
105
+ * The last two are the same class again, and they were silent in the same way.
106
+ * `resolveActionParam` seeds a `defaultFromRow` param only when the row HAS the
107
+ * key (an own-property check), so an absent key opened the param dialog with the
108
+ * field blank; and the console's `api` handler fills a `{field}` token from the
109
+ * row, so an absent key put an empty path segment into the URL. The key a param
110
+ * seeds from is `field ?? name` — the runtime's precedence, not the param's
111
+ * payload name — and the token grammar is the handler's own. Neither is
112
+ * narrowed by the action's `type`, for the asymmetry stated below: an extra
113
+ * column costs bytes.
114
+ *
115
+ * The written fields are the same class once more, and the worst of them,
116
+ * because what went wrong was a write. The Undo capture of an `undoable` update
117
+ * reads each written field's prior value off the row; on a projected row a
118
+ * written field no column shows was absent, the capture recorded `null`, and
119
+ * Undo then wrote `null` over the stored value it existed to restore. The
120
+ * written keys are `patch`, each param's collected key and `bodyExtra` (see
121
+ * `undoableWrittenKeys`). This one IS narrowed, to `undoable`, and not for
122
+ * cost: the capture is the only reader of a written field's prior value, and it
123
+ * runs only under `undoable`, so without it the row reads none of these keys.
124
+ *
125
+ * A `recordIdField`, param key or written key that is not a bare identifier is
126
+ * dropped here
127
+ * (a `target` token is one by construction of its grammar), and a name the
128
+ * object does not declare is dropped by the caller's `isProjectableField`
129
+ * guard. Both drops are the safe direction: such a name is not a column
130
+ * anywhere, and an unknown key in `$select` is not ignored by every backend.
131
+ * The identifier gate is not decoration — without it the harvester's regex would
132
+ * read a PREFIX of a malformed name (`record.not a field` → `not`) and
133
+ * contribute a plausible wrong field instead of nothing.
134
+ *
135
+ * ⛔ Harvesting is not a read grant. Every name here is a CANDIDATE: each
136
+ * consumer still passes it through its own field-level read filter before it
137
+ * reaches `$select`, so a field the principal may not read is not requested
138
+ * because an action names it.
81
139
  *
82
140
  * `objectActions` is deliberately the object's WHOLE action set rather than
83
141
  * only the ones this view promotes. Narrowing it would mean re-running the