@object-ui/core 17.6.0 → 18.0.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 (147) hide show
  1. package/CHANGELOG.md +7260 -0
  2. package/README.md +228 -14
  3. package/dist/actions/ActionEngine.d.ts +4 -2
  4. package/dist/actions/ActionEngine.js +13 -8
  5. package/dist/actions/ActionRunner.d.ts +473 -69
  6. package/dist/actions/ActionRunner.js +792 -125
  7. package/dist/actions/TransactionManager.d.ts +7 -2
  8. package/dist/actions/UndoManager.d.ts +8 -13
  9. package/dist/actions/actionKeys.d.ts +9 -7
  10. package/dist/actions/actionKeys.js +67 -14
  11. package/dist/actions/actionResponse.js +2 -2
  12. package/dist/actions/index.d.ts +2 -0
  13. package/dist/actions/index.js +2 -0
  14. package/dist/actions/navigationTarget.d.ts +41 -0
  15. package/dist/actions/navigationTarget.js +20 -0
  16. package/dist/actions/recordDelete.d.ts +154 -0
  17. package/dist/actions/recordDelete.js +110 -0
  18. package/dist/adapters/ApiDataSource.d.ts +8 -4
  19. package/dist/adapters/ApiDataSource.js +19 -7
  20. package/dist/adapters/ValueDataSource.d.ts +6 -1
  21. package/dist/adapters/ValueDataSource.js +1090 -98
  22. package/dist/adapters/batchTransaction.d.ts +1 -1
  23. package/dist/adapters/batchTransaction.js +1 -1
  24. package/dist/adapters/resolveDataSource.d.ts +9 -6
  25. package/dist/adapters/resolveDataSource.js +9 -12
  26. package/dist/builder/schema-builder.d.ts +38 -81
  27. package/dist/builder/schema-builder.js +33 -152
  28. package/dist/data-scope/ViewDataProvider.d.ts +8 -25
  29. package/dist/data-scope/ViewDataProvider.js +27 -54
  30. package/dist/data-scope/element-data-source.d.ts +261 -7
  31. package/dist/data-scope/element-data-source.js +342 -52
  32. package/dist/data-scope/index.d.ts +9 -4
  33. package/dist/data-scope/index.js +9 -4
  34. package/dist/evaluator/ExpressionEvaluator.d.ts +86 -0
  35. package/dist/evaluator/ExpressionEvaluator.js +184 -12
  36. package/dist/evaluator/FormulaFunctions.d.ts +0 -10
  37. package/dist/evaluator/FormulaFunctions.js +166 -27
  38. package/dist/evaluator/deadlineSettled.d.ts +40 -0
  39. package/dist/evaluator/deadlineSettled.js +45 -0
  40. package/dist/evaluator/declaredPredicate.d.ts +93 -9
  41. package/dist/evaluator/declaredPredicate.js +117 -16
  42. package/dist/evaluator/fieldRules.d.ts +88 -4
  43. package/dist/evaluator/fieldRules.js +208 -31
  44. package/dist/evaluator/index.d.ts +2 -0
  45. package/dist/evaluator/index.js +2 -0
  46. package/dist/evaluator/listConditional.d.ts +103 -34
  47. package/dist/evaluator/listConditional.js +127 -50
  48. package/dist/evaluator/optionRules.d.ts +77 -6
  49. package/dist/evaluator/optionRules.js +87 -10
  50. package/dist/evaluator/predicateInput.d.ts +15 -4
  51. package/dist/evaluator/predicateInput.js +35 -2
  52. package/dist/evaluator/rowPredicateCanon.d.ts +7 -18
  53. package/dist/evaluator/rowPredicateCanon.js +65 -115
  54. package/dist/evaluator/subjectPermissions.d.ts +105 -0
  55. package/dist/evaluator/subjectPermissions.js +86 -0
  56. package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
  57. package/dist/evaluator/unevaluablePredicate.js +70 -0
  58. package/dist/index.d.ts +15 -0
  59. package/dist/index.js +83 -2
  60. package/dist/protocols/index.d.ts +0 -1
  61. package/dist/protocols/index.js +9 -1
  62. package/dist/registry/Registry.d.ts +382 -34
  63. package/dist/registry/Registry.js +247 -12
  64. package/dist/registry/WidgetRegistry.js +7 -3
  65. package/dist/registry/html-tier-intrinsics.d.ts +90 -0
  66. package/dist/registry/html-tier-intrinsics.js +110 -0
  67. package/dist/registry/public-blocks.d.ts +8 -0
  68. package/dist/registry/public-blocks.js +57 -0
  69. package/dist/theme/ThemeEngine.js +1 -1
  70. package/dist/utils/affordanceGrants.d.ts +371 -0
  71. package/dist/utils/affordanceGrants.js +252 -0
  72. package/dist/utils/chart-category-key.d.ts +82 -0
  73. package/dist/utils/chart-category-key.js +84 -0
  74. package/dist/utils/chart-measure-key.d.ts +64 -0
  75. package/dist/utils/chart-measure-key.js +65 -0
  76. package/dist/utils/chart-presentation.d.ts +95 -112
  77. package/dist/utils/chart-presentation.js +169 -104
  78. package/dist/utils/chart-series.d.ts +165 -5
  79. package/dist/utils/chart-series.js +123 -18
  80. package/dist/utils/column-sortability.d.ts +157 -0
  81. package/dist/utils/column-sortability.js +217 -0
  82. package/dist/utils/dashboard-filters.d.ts +38 -6
  83. package/dist/utils/dashboard-filters.js +107 -61
  84. package/dist/utils/dataset-format.d.ts +211 -30
  85. package/dist/utils/dataset-format.js +389 -46
  86. package/dist/utils/date-display.d.ts +346 -0
  87. package/dist/utils/date-display.js +644 -0
  88. package/dist/utils/date-macros.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 +81 -9
  94. package/dist/utils/expand-fields.js +81 -9
  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 +158 -6
  98. package/dist/utils/filter-converter.js +1098 -24
  99. package/dist/utils/filter-tokens.d.ts +49 -5
  100. package/dist/utils/filter-tokens.js +105 -33
  101. package/dist/utils/grouping-fields.d.ts +61 -0
  102. package/dist/utils/grouping-fields.js +84 -0
  103. package/dist/utils/managedBy.d.ts +3 -3
  104. package/dist/utils/managedBy.js +5 -5
  105. package/dist/utils/native-date-value.d.ts +48 -0
  106. package/dist/utils/native-date-value.js +167 -0
  107. package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
  108. package/dist/utils/non-grid-row-ceiling.js +141 -0
  109. package/dist/utils/normalize-list-view.d.ts +135 -11
  110. package/dist/utils/normalize-list-view.js +243 -64
  111. package/dist/utils/number-display.d.ts +63 -14
  112. package/dist/utils/number-display.js +34 -12
  113. package/dist/utils/object-aggregate-query.d.ts +113 -0
  114. package/dist/utils/object-aggregate-query.js +50 -0
  115. package/dist/utils/parent-scope.d.ts +123 -0
  116. package/dist/utils/parent-scope.js +140 -0
  117. package/dist/utils/predicate-fields.d.ts +45 -12
  118. package/dist/utils/predicate-fields.js +141 -12
  119. package/dist/utils/record-color.d.ts +75 -0
  120. package/dist/utils/record-color.js +65 -0
  121. package/dist/utils/record-source.d.ts +298 -0
  122. package/dist/utils/record-source.js +331 -0
  123. package/dist/utils/record-title.d.ts +148 -8
  124. package/dist/utils/record-title.js +220 -26
  125. package/dist/utils/reference-keys.d.ts +21 -22
  126. package/dist/utils/reference-keys.js +532 -29
  127. package/dist/utils/sort-query.d.ts +115 -15
  128. package/dist/utils/sort-query.js +240 -13
  129. package/dist/utils/text-comparand.d.ts +92 -0
  130. package/dist/utils/text-comparand.js +99 -0
  131. package/dist/utils/without-denied-fields.d.ts +49 -0
  132. package/dist/utils/without-denied-fields.js +52 -0
  133. package/dist/validation/index.d.ts +8 -4
  134. package/dist/validation/index.js +8 -4
  135. package/dist/validation/schema-validator.d.ts +1 -1
  136. package/dist/validation/schema-validator.js +225 -83
  137. package/dist/validation/server-owned-value.d.ts +5 -1
  138. package/dist/validation/server-owned-value.js +5 -1
  139. package/dist/validation/validators/object-validation-engine.d.ts +1 -1
  140. package/dist/validation/validators/object-validation-engine.js +14 -3
  141. package/package.json +8 -9
  142. package/dist/data-scope/DataScopeManager.d.ts +0 -127
  143. package/dist/data-scope/DataScopeManager.js +0 -229
  144. package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
  145. package/dist/protocols/ResponsiveProtocol.js +0 -158
  146. package/dist/validation/validation-engine.d.ts +0 -88
  147. package/dist/validation/validation-engine.js +0 -434
@@ -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
+ }
@@ -32,7 +32,10 @@
32
32
  * predicate.)
33
33
  *
34
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
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
36
39
  * "predicate" spelling because the mechanism is identical (harvest a name, gate
37
40
  * it against the declared fields, add it to `$select`); see
38
41
  * {@link listViewPredicates} for what is in the set and why.
@@ -85,24 +88,54 @@ export declare function collectPredicateFieldRefs(predicates: readonly unknown[]
85
88
  * kebab (custom defs AND the built-in Edit/Delete overrides), the selection bar,
86
89
  * and the object's declared actions.
87
90
  *
88
- * Most entries are predicates. Two are not, and both are spelled as a synthetic
91
+ * Most entries are predicates. Five are not, and all are spelled as a synthetic
89
92
  * `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
+ * 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
93
99
  * `rowRecord[action.recordIdField]` to seed `recordIdParam`, so a key outside the
94
100
  * listView columns arrived absent and the injection was silently skipped, which
95
101
  * turns a record-scoped mutation into one that names no record while still
96
102
  * reporting success. The default (`id`) is already projected unconditionally, so
97
103
  * only an explicit declaration adds anything here.
98
104
  *
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.
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.
106
139
  *
107
140
  * `objectActions` is deliberately the object's WHOLE action set rather than
108
141
  * only the ones this view promotes. Narrowing it would mean re-running the