@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
@@ -9,19 +9,28 @@
9
9
  * sort-query — lower a block's authored `sort` to the `$orderby` value a
10
10
  * `DataSource.find` query carries.
11
11
  *
12
- * Two authored spellings reach every object-bound block, because both are
13
- * declared: the legacy OData-ish string (`ObjectGridSchema.sort: "name desc"`)
14
- * and the spec's `SortConfig[]` (`[{ field, order }]`). A read site that hands
15
- * either straight to `$orderby` works only because `QueryParams.$orderby`
16
- * accepts four shapes; normalizing here means one shape reaches the wire and a
17
- * block does not have to know which spelling its author used.
12
+ * ONE authored spelling reaches every object-bound block: the spec's
13
+ * `SortConfig[]` (`[{ field, order }]`). The legacy OData-ish string clause
14
+ * (`"name desc"`) that this function also honoured until objectui#8221 is
15
+ * RETIRED — director ruling, decision batch #77, 2026-09-07 (objectui#8221,
16
+ * maintainer approved), option B: "The platform has one `sort` spelling, the
17
+ * array, everywhere."
18
+ *
19
+ * Why the string had to go rather than be declared alongside the array: every
20
+ * `sort` input in the registry publishes `type: 'array'` alone, so the html
21
+ * tier already answered `type-mismatch` for the string, and
22
+ * `@objectstack/spec` refuses it outright on `element-record-picker`. A
23
+ * spelling that core implements, the docs teach and the validator rejects is
24
+ * three faces disagreeing, and the ruling settled which one wins. Declaring
25
+ * per-block string arms instead (option A) was rejected by name: it would make
26
+ * one key mean different things on different blocks.
18
27
  *
19
28
  * This is now the ONLY definition in the repo. It was hoisted here because
20
29
  * objectstack#7137 added two more read sites (`object-timeline`,
21
30
  * `record:line_items`) next to three sibling blocks that each inlined a
22
31
  * byte-identical private copy (`ObjectGantt` / `ObjectMap` / `ObjectCalendar`),
23
32
  * 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
33
+ * three copies were deliberately left alone by objectstack-ai/objectstack#7137 and collapsed onto this
25
34
  * function by objectui#4022; every block now imports it from here.
26
35
  *
27
36
  * Two deliberate differences from those retired private copies, both of which
@@ -29,29 +38,120 @@
29
38
  * tolerance — and both are BEHAVIOUR CHANGES the migration delivered, not pure
30
39
  * refactor:
31
40
  *
32
- * - **`order` is optional in `SortConfig`**, so an entry that omits it means
33
- * ascending (that is what `$orderby`'s own
34
- * `Array<{ field: string; order?: 'asc' | 'desc' }>` shape says). The private
35
- * copies required BOTH keys and silently dropped such an entry, which lost an
41
+ * - **A missing `order` is READ as ascending rather than dropped.** ⚠️ This
42
+ * bullet used to say `order` is "optional in `SortConfig`". It is not:
43
+ * `SortConfig.order` is required on the interface, on its zod mirror, and on
44
+ * `@objectstack/spec`'s `SortItemSchema`, which refuses an entry without it
45
+ * (`invalid_value` at `0.order`, measured on `@objectstack/spec@17.4.0`).
46
+ * Corrected under objectui#8973, which is where objectui#8767's contract
47
+ * review routed this sentence and its twin in
48
+ * `@object-ui/types`' `ObjectGridSchema.sort` docblock. The tolerance is
49
+ * real but it is a RUNTIME one — types are erased, so an entry missing
50
+ * `order` still arrives and still has to mean something. The private copies
51
+ * required BOTH keys and silently dropped such an entry, which lost an
36
52
  * authored sort key instead of ordering by it.
37
53
  * - **Nothing usable yields `undefined`, never `{}`.** An empty object is a
38
54
  * truthy value that means "no ordering" only by accident of the adapter's
39
55
  * serializer; `undefined` says it, so the query simply carries no `$orderby`.
40
56
  *
41
57
  * Not a lenient alias: an unusable input (a number, an object, an array of
42
- * strings) yields `undefined` rather than a guess. Only the two spellings the
43
- * schema types declare are honoured.
58
+ * strings) yields `undefined` rather than a guess. Only the ONE spelling the
59
+ * schema types declare is honoured.
60
+ *
61
+ * ⚠️ A retired string is REFUSED OUT LOUD, not dropped in silence. Types are
62
+ * erased, so the narrowed signature below cannot stop a string: authored JSON,
63
+ * a stored `sys_metadata` row and an `as any` bag all reach this function
64
+ * unparsed. Returning `undefined` and saying nothing would turn every one of
65
+ * those into a row order that quietly stopped applying — the exact failure this
66
+ * repository has measured over and over. See {@link convertSortToQueryParams}.
67
+ *
68
+ * ⚠️ OUT LOUD is about the SPELLING, not about the container it arrives in
69
+ * (objectui#9955). `sort: ['name asc']` is the same retired clause one level
70
+ * down, and it used to reach the array arm's `continue` and leave through the
71
+ * `undefined` return with nothing printed — the loud scalar refusal sat one arm
72
+ * above it, so the two spellings of one mistake got opposite treatment. Both are
73
+ * loud now. ⛔ Being loud is NOT accepting: the sentence four paragraphs up
74
+ * ("an unusable input … yields `undefined` rather than a guess") still holds
75
+ * byte for byte, and `['name asc']` still contributes no ordering. The ruling
76
+ * quoted at the top of this docblock — one `sort` spelling, the array,
77
+ * everywhere — is the reason making it WORK was never on the table.
44
78
  */
45
79
  /** A field name paired with a direction — the spec's `SortConfig`. */
46
80
  export interface QuerySortEntry {
47
81
  field?: string;
48
82
  order?: 'asc' | 'desc';
49
83
  }
84
+ /**
85
+ * Test seam — forget which retired spellings have been reported.
86
+ *
87
+ * Needed because the dedupe above is module state: without it the second test
88
+ * to assert the refusal diagnostic would observe silence and pass for the
89
+ * wrong reason.
90
+ */
91
+ export declare function resetRetiredSortSpellingReports(): void;
92
+ /**
93
+ * A sort entry after normalization — BOTH keys present, always. That is the
94
+ * difference from {@link QuerySortEntry}, whose two members are optional
95
+ * because it describes what an author may WRITE, not what survives this door.
96
+ */
97
+ export interface NormalizedSortEntry {
98
+ field: string;
99
+ order: 'asc' | 'desc';
100
+ }
101
+ /**
102
+ * Decide WHICH authored entries survive and WHAT a missing direction means —
103
+ * the whole of this module's normalization, with no opinion about the wire.
104
+ *
105
+ * Split out of {@link convertSortToQueryParams} by objectui#8973, which measured
106
+ * `object-grid` re-implementing this decision privately and getting it wrong:
107
+ * its array arm interpolated every key unconditionally, so an entry missing
108
+ * `field` or `order` reached the wire as the literal text `undefined`
109
+ * (`$orderby: 'name undefined'`), which the server answers `400 INVALID_QUERY`.
110
+ *
111
+ * ⚠️ It is SHAPE-AGNOSTIC on purpose, and that is what makes it reusable where
112
+ * the map below is not. `object-grid` sends a `"field order"` join string, not
113
+ * this module's `{field: direction}` map; swapping its wire shape is route B on
114
+ * objectui#8767, which the maintainer DECLINED by name (2026-09-10) pending a
115
+ * card that measures the server contract and both readers. Exporting the
116
+ * decision without the map lets that block share the ONE implementation of the
117
+ * rule while keeping the wire shape the declination protects — so "one
118
+ * operation, one implementation" is satisfied for the operation that actually
119
+ * has two copies, rather than being traded away for a shape change nobody
120
+ * ruled on.
121
+ *
122
+ * The two rules, unchanged from the map builder they were lifted out of:
123
+ *
124
+ * - an entry with no usable `field` is SKIPPED (it names nothing to order by);
125
+ * - `order` normalizes to `'asc'` unless it is exactly `'desc'`, so a missing
126
+ * direction means ascending and a garbage one does not reach the wire.
127
+ *
128
+ * A third rule was added by objectui#9955 and it changes no RETURN value: a
129
+ * STRING entry is the retired clause one container deeper, so it is now REFUSED
130
+ * OUT LOUD before the skip above reaches it — and then skipped, contributing
131
+ * nothing, exactly as before. ⛔ The two rules above are untouched by it; in
132
+ * particular the entry-shaped object with no `field` keeps its DELIBERATE
133
+ * silence, because it names nothing to order by rather than naming it in a
134
+ * retired spelling. The seam is `typeof entry === 'string'` and only that.
135
+ *
136
+ * @returns The surviving entries in authored order, or `undefined` when nothing
137
+ * orderable was authored — never an empty array, so callers can omit the
138
+ * query key entirely rather than asking for an ordering with no content.
139
+ */
140
+ export declare function normalizeSortEntries(sort: QuerySortEntry[] | undefined | null): NormalizedSortEntry[] | undefined;
50
141
  /**
51
142
  * Normalize an authored `sort` into the field→direction map used for
52
143
  * `QueryParams.$orderby`.
53
144
  *
54
- * @param sort `"name desc"` (legacy string) or `SortConfig[]`.
145
+ * The decision about which entries survive lives in
146
+ * {@link normalizeSortEntries}; this function is only the MAP projection of it.
147
+ * Duplicate fields therefore collapse last-wins, keeping the position of the
148
+ * first mention — the same thing the single-pass loop this replaced did, since
149
+ * an object key keeps its insertion position when it is re-assigned.
150
+ *
151
+ * @param sort `SortConfig[]` — the one declared spelling. The legacy string
152
+ * clause (`"name desc"`) is retired (objectui#8221): a string that reaches here
153
+ * at runtime is refused with a diagnostic naming the array form, and this
154
+ * function returns `undefined` so the query carries no `$orderby`.
55
155
  * @returns The ordering map, or `undefined` when nothing orderable was authored.
56
156
  */
57
- export declare function convertSortToQueryParams(sort: string | QuerySortEntry[] | undefined | null): Record<string, 'asc' | 'desc'> | undefined;
157
+ export declare function convertSortToQueryParams(sort: QuerySortEntry[] | undefined | null): Record<string, 'asc' | 'desc'> | undefined;
@@ -6,30 +6,257 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  /**
9
- * Normalize an authored `sort` into the field→direction map used for
10
- * `QueryParams.$orderby`.
9
+ * The canonical spelling, quoted in the refusal diagnostic so the message
10
+ * carries the fix and not just the complaint.
11
11
  *
12
- * @param sort `"name desc"` (legacy string) or `SortConfig[]`.
13
- * @returns The ordering map, or `undefined` when nothing orderable was authored.
12
+ * ⚠️ BOTH keys, deliberately. `order` is REQUIRED on `SortConfig`, on its zod
13
+ * counterpart and on `@objectstack/spec`'s `SortItemSchema`, so an example that
14
+ * omitted it would make this diagnostic prescribe metadata a publish refuses —
15
+ * objectui#9031. The pin feeds this very example back through `SortItemSchema`
16
+ * from the installed artifact rather than eyeballing it, because the way this
17
+ * regresses is somebody "simplifying" the example, not somebody editing prose.
14
18
  */
15
- export function convertSortToQueryParams(sort) {
19
+ const ARRAY_FORM_EXAMPLE = "[{ field: 'name', order: 'desc' }]";
20
+ /**
21
+ * Retired string clauses already reported, so one bad `sort` logs its
22
+ * prescription ONCE instead of once per render. The message is a fix
23
+ * instruction for an author, not a per-render event, and this sink runs inside
24
+ * the query memo of every object-bound block — a related-list derivation over
25
+ * an object with N lists would otherwise print N lines per pass.
26
+ *
27
+ * Module state, exactly as {@link resetRetiredSortSpellingReports}'s sibling
28
+ * `resetRetiredFieldTypeReports` keeps it for retired field types: the dedupe
29
+ * is per SPELLING, so two blocks that inherit the same bad view sort still
30
+ * print one line between them.
31
+ *
32
+ * ⚠️ It holds keys, not raw spellings — the two arms that report are ARM-TAGGED
33
+ * (objectui#9955). A retired string arrives either as the whole `sort` or as one
34
+ * entry of the array, and the two get DIFFERENT messages (what a reader must
35
+ * change, and what the query still carries, are not the same in the two cases).
36
+ * On one shared raw key the first arm to fire would silence the other, and the
37
+ * author would read a sentence about the wrong container. {@link retiredKey}
38
+ * builds the keys so the two spaces cannot collide, and the bound each arm keeps
39
+ * is unchanged: ONE line per distinct spelling per arm, however many blocks
40
+ * inherit the same bad view.
41
+ */
42
+ const reportedRetiredSpellings = new Set();
43
+ /**
44
+ * Key a report by the arm that produced it AND the spelling that produced it.
45
+ *
46
+ * The two spaces are disjoint by construction rather than by luck: `arm` is one
47
+ * of two fixed tokens, neither of which contains a newline, so the text before
48
+ * the first newline identifies the arm no matter what an author wrote. A raw
49
+ * spelling can be anything at all — it comes out of authored JSON — so a bare
50
+ * string prefix would only be disjoint until somebody authored the prefix.
51
+ */
52
+ function retiredKey(arm, spelling) {
53
+ return `${arm}\n${spelling}`;
54
+ }
55
+ /**
56
+ * Test seam — forget which retired spellings have been reported.
57
+ *
58
+ * Needed because the dedupe above is module state: without it the second test
59
+ * to assert the refusal diagnostic would observe silence and pass for the
60
+ * wrong reason.
61
+ */
62
+ export function resetRetiredSortSpellingReports() {
63
+ reportedRetiredSpellings.clear();
64
+ }
65
+ /**
66
+ * Name a retired string `sort` clause, once per spelling.
67
+ *
68
+ * `console.error`, not `warn`, and not dev-gated: this call REFUSES an authored
69
+ * row order, so the page renders in a different order than the author asked
70
+ * for. That is the same severity class as `reportRetiredFieldType`, which is
71
+ * also unconditional, and the opposite of `warnOnUnknownActionKeys`, which only
72
+ * reports keys nothing was ever going to read.
73
+ *
74
+ * ⚠️ This text is read at the moment the author is ALREADY being corrected, so
75
+ * it must not prescribe metadata the spec refuses (objectui#9031). It used to
76
+ * end "`order` is optional and means `'asc'`" — a RUNTIME tolerance stated as
77
+ * an AUTHORING permission. Both halves of that are load-bearing and both have to
78
+ * survive any rewording:
79
+ *
80
+ * - the tolerance is REAL — a missing `order` is read as ascending here rather
81
+ * than dropped (see {@link normalizeSortEntries}), because types are erased
82
+ * and an entry that arrives without it still has to mean something;
83
+ * - and it is NOT permission — `order` is required on `SortConfig`, on its zod
84
+ * counterpart and on `@objectstack/spec`'s `SortItemSchema`, which refuses an
85
+ * entry without it.
86
+ *
87
+ * Stating only the first is what made an author who followed this correction
88
+ * verbatim get refused a second time, at publish, by a different door. Deleting
89
+ * the tolerance instead would be the opposite error: it is what this renderer
90
+ * actually does, and a diagnostic that denies it sends the author hunting for a
91
+ * dropped sort key that was never dropped.
92
+ */
93
+ function reportRetiredSortSpelling(sort) {
94
+ const key = retiredKey('scalar', sort);
95
+ if (reportedRetiredSpellings.has(key))
96
+ return;
97
+ reportedRetiredSpellings.add(key);
98
+ console.error(`[object-ui] convertSortToQueryParams: the legacy string \`sort\` clause is retired ` +
99
+ `(objectui#8221) and was REFUSED — received ${JSON.stringify(sort)}, so this query ` +
100
+ `carries no \`$orderby\`. Write the array form instead: ` +
101
+ `sort: ${ARRAY_FORM_EXAMPLE} — both keys, on every entry. \`order\` is required: on ` +
102
+ `\`SortConfig\`, on its zod counterpart, and on \`@objectstack/spec\`'s \`SortItemSchema\`, ` +
103
+ `which refuses an entry without it. (A missing \`order\` is still read as \`'asc'\` ` +
104
+ `here — that is a runtime tolerance, not permission to omit the key: metadata ` +
105
+ `written that way is refused when it is published.) ` +
106
+ `The array is the only spelling every \`sort\` input declares, and the only one ` +
107
+ `\`@objectstack/spec\` accepts.`);
108
+ }
109
+ /**
110
+ * Name a retired string clause that arrived as an ENTRY of the `sort` array,
111
+ * once per spelling (objectui#9955).
112
+ *
113
+ * A sibling of {@link reportRetiredSortSpelling} rather than a reuse of it, and
114
+ * the difference is the one sentence an author acts on: the scalar refusal can
115
+ * say "this query carries no `$orderby`" because refusing the whole value is
116
+ * refusing the whole ordering, while one bad entry among good ones leaves the
117
+ * other entries lowering normally — `[{ field: 'a', order: 'asc' }, 'b desc']`
118
+ * still orders by `a`. Reusing the scalar text here would tell an author their
119
+ * rows are unordered when they are ordered by everything except the key they
120
+ * are hunting for, which is worse than silence.
121
+ *
122
+ * Everything the scalar text carries, this one carries too, because objectui#9031
123
+ * ruled BOTH halves load-bearing and a reader reaching this message is being
124
+ * corrected in exactly the same moment: the worked array-of-objects example, the
125
+ * fact that `order` is REQUIRED at publish, and the fact that this renderer's
126
+ * ascending default is a RUNTIME tolerance and ⛔ not permission to omit the key.
127
+ *
128
+ * The caller withholds exactly one string from this reporter — `''` — because
129
+ * `sort: ''` does not reach the scalar reporter either (the `!sort` guard reads
130
+ * an empty spelling as "the author wrote nothing", and its pin says reporting it
131
+ * would train readers to ignore the message). Mirroring that is the point of the
132
+ * whole card; inverting it one container deeper would recreate the same
133
+ * inconsistency in the other direction.
134
+ *
135
+ * `console.error`, unconditional and not dev-gated — the same choice, for the
136
+ * same reason, as its scalar sibling: this refuses an authored row order, so the
137
+ * page renders in an order the author did not ask for. ⛔ Not the
138
+ * `warnOnUnknownActionKeys` class, which reports keys nothing was ever going to
139
+ * read; here something WAS going to be read and now is not.
140
+ */
141
+ function reportRetiredSortEntrySpelling(entry) {
142
+ const key = retiredKey('entry', entry);
143
+ if (reportedRetiredSpellings.has(key))
144
+ return;
145
+ reportedRetiredSpellings.add(key);
146
+ console.error(`[object-ui] convertSortToQueryParams: a \`sort\` ARRAY ENTRY is the retired string ` +
147
+ `clause (objectui#8221) and was REFUSED — received ${JSON.stringify(entry)} as an entry ` +
148
+ `of the \`sort\` array, so that entry orders nothing (and if it was the only usable ` +
149
+ `entry, this query carries no \`$orderby\` at all). The retirement is about the ` +
150
+ `SPELLING, not the container: wrapping the clause in an array does not revive it. ` +
151
+ `Write entry OBJECTS instead: sort: ${ARRAY_FORM_EXAMPLE} — both keys, on every entry. ` +
152
+ `\`order\` is required: on \`SortConfig\`, on its zod counterpart, and on ` +
153
+ `\`@objectstack/spec\`'s \`SortItemSchema\`, which refuses an entry without it. ` +
154
+ `(A missing \`order\` is still read as \`'asc'\` here — that is a runtime tolerance, ` +
155
+ `not permission to omit the key: metadata written that way is refused when it is ` +
156
+ `published.) The array OF OBJECTS is the only spelling every \`sort\` input declares, ` +
157
+ `and the only one \`@objectstack/spec\` accepts.`);
158
+ }
159
+ /**
160
+ * Decide WHICH authored entries survive and WHAT a missing direction means —
161
+ * the whole of this module's normalization, with no opinion about the wire.
162
+ *
163
+ * Split out of {@link convertSortToQueryParams} by objectui#8973, which measured
164
+ * `object-grid` re-implementing this decision privately and getting it wrong:
165
+ * its array arm interpolated every key unconditionally, so an entry missing
166
+ * `field` or `order` reached the wire as the literal text `undefined`
167
+ * (`$orderby: 'name undefined'`), which the server answers `400 INVALID_QUERY`.
168
+ *
169
+ * ⚠️ It is SHAPE-AGNOSTIC on purpose, and that is what makes it reusable where
170
+ * the map below is not. `object-grid` sends a `"field order"` join string, not
171
+ * this module's `{field: direction}` map; swapping its wire shape is route B on
172
+ * objectui#8767, which the maintainer DECLINED by name (2026-09-10) pending a
173
+ * card that measures the server contract and both readers. Exporting the
174
+ * decision without the map lets that block share the ONE implementation of the
175
+ * rule while keeping the wire shape the declination protects — so "one
176
+ * operation, one implementation" is satisfied for the operation that actually
177
+ * has two copies, rather than being traded away for a shape change nobody
178
+ * ruled on.
179
+ *
180
+ * The two rules, unchanged from the map builder they were lifted out of:
181
+ *
182
+ * - an entry with no usable `field` is SKIPPED (it names nothing to order by);
183
+ * - `order` normalizes to `'asc'` unless it is exactly `'desc'`, so a missing
184
+ * direction means ascending and a garbage one does not reach the wire.
185
+ *
186
+ * A third rule was added by objectui#9955 and it changes no RETURN value: a
187
+ * STRING entry is the retired clause one container deeper, so it is now REFUSED
188
+ * OUT LOUD before the skip above reaches it — and then skipped, contributing
189
+ * nothing, exactly as before. ⛔ The two rules above are untouched by it; in
190
+ * particular the entry-shaped object with no `field` keeps its DELIBERATE
191
+ * silence, because it names nothing to order by rather than naming it in a
192
+ * retired spelling. The seam is `typeof entry === 'string'` and only that.
193
+ *
194
+ * @returns The surviving entries in authored order, or `undefined` when nothing
195
+ * orderable was authored — never an empty array, so callers can omit the
196
+ * query key entirely rather than asking for an ordering with no content.
197
+ */
198
+ export function normalizeSortEntries(sort) {
16
199
  if (!sort)
17
200
  return undefined;
18
- // Legacy string clause: "name desc" / "name" (a bare field means ascending).
201
+ // Retired spelling — reachable only at runtime, since the signature above no
202
+ // longer admits it. Read through `unknown` on purpose: the check is about the
203
+ // VALUE that actually arrived, not about the type the caller promised.
19
204
  if (typeof sort === 'string') {
20
- const [field, direction] = sort.trim().split(/\s+/);
21
- if (!field)
22
- return undefined;
23
- return { [field]: direction?.toLowerCase() === 'desc' ? 'desc' : 'asc' };
205
+ reportRetiredSortSpelling(sort);
206
+ return undefined;
24
207
  }
25
208
  if (Array.isArray(sort)) {
26
- const out = {};
209
+ const out = [];
27
210
  for (const entry of sort) {
211
+ // The retired spelling, one container deeper (objectui#9955). Read through
212
+ // `unknown` for the same reason the scalar arm above does: the check is
213
+ // about the VALUE that arrived, not the type the caller promised. ⛔ This
214
+ // branch does not ACCEPT the string — the entry still contributes nothing,
215
+ // exactly as it did when it fell through the skip below. All it adds is the
216
+ // sentence that says so.
217
+ //
218
+ // The one string it does not name is `''`, and that is SYMMETRY with the
219
+ // scalar arm rather than an exception to the seam: `sort: ''` never reaches
220
+ // the loud arm above either, because the `!sort` guard reads it as "the
221
+ // author wrote nothing" rather than as the retired clause. Whitespace is
222
+ // NOT in that carve-out — `' '` is truthy, is loud on the scalar arm
223
+ // today, and stays loud here.
224
+ if (typeof entry === 'string') {
225
+ const spelling = entry;
226
+ if (spelling !== '')
227
+ reportRetiredSortEntrySpelling(spelling);
228
+ continue;
229
+ }
28
230
  if (!entry || typeof entry.field !== 'string' || entry.field === '')
29
231
  continue;
30
- out[entry.field] = entry.order === 'desc' ? 'desc' : 'asc';
232
+ out.push({ field: entry.field, order: entry.order === 'desc' ? 'desc' : 'asc' });
31
233
  }
32
- return Object.keys(out).length > 0 ? out : undefined;
234
+ return out.length > 0 ? out : undefined;
33
235
  }
34
236
  return undefined;
35
237
  }
238
+ /**
239
+ * Normalize an authored `sort` into the field→direction map used for
240
+ * `QueryParams.$orderby`.
241
+ *
242
+ * The decision about which entries survive lives in
243
+ * {@link normalizeSortEntries}; this function is only the MAP projection of it.
244
+ * Duplicate fields therefore collapse last-wins, keeping the position of the
245
+ * first mention — the same thing the single-pass loop this replaced did, since
246
+ * an object key keeps its insertion position when it is re-assigned.
247
+ *
248
+ * @param sort `SortConfig[]` — the one declared spelling. The legacy string
249
+ * clause (`"name desc"`) is retired (objectui#8221): a string that reaches here
250
+ * at runtime is refused with a diagnostic naming the array form, and this
251
+ * function returns `undefined` so the query carries no `$orderby`.
252
+ * @returns The ordering map, or `undefined` when nothing orderable was authored.
253
+ */
254
+ export function convertSortToQueryParams(sort) {
255
+ const entries = normalizeSortEntries(sort);
256
+ if (!entries)
257
+ return undefined;
258
+ const out = {};
259
+ for (const entry of entries)
260
+ out[entry.field] = entry.order;
261
+ return out;
262
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * The ONE `icontains` comparand ruling this package owns, in one place
10
+ * (objectui#9048).
11
+ *
12
+ * ## Why this module exists rather than a third copy
13
+ *
14
+ * `@objectstack/spec`'s `FILTER_TEXT_CASES` carries two REJECTION rows for the
15
+ * case-insensitive contains operator — an empty comparand and a non-string one
16
+ * — each with `code: 'INVALID_FILTER'` and `mustMention: ['$icontains']`.
17
+ * Before this module the SAME discrimination and the SAME message were written
18
+ * out twice inside this package: `ValueDataSource`'s `refuseTextComparand`
19
+ * (objectui#8748, the reference implementation) and `filter-converter`'s
20
+ * (objectui#9001, whose own docblock calls itself "a PORT of a shipped,
21
+ * reviewed implementation"). objectui#9048's triage ruled on that directly:
22
+ *
23
+ * > three implementations of one refusal, of which one is correct ⇒ the useful
24
+ * > dispatch is not "add a third guard" but "make the dialects share the one
25
+ * > that works"
26
+ *
27
+ * So the contract's half — WHICH comparands are refused, and WHAT the author is
28
+ * told — lives here once, and each face keeps only its own ENVELOPE. That split
29
+ * is not a tidy-up: it is the half that does and the half that does not
30
+ * transfer, and both docblocks already said so before this module existed.
31
+ * `ValueDataSource` excludes-and-logs because it is deciding about one ROW and
32
+ * HAS a row to exclude; `filter-converter` is a PRODUCER deciding whether to
33
+ * send a query at all and has none, so it throws `FilterOperatorError`
34
+ * (`INVALID_FILTER` / 400).
35
+ *
36
+ * ## Not exported from `@object-ui/core`'s entry, deliberately
37
+ *
38
+ * `src/index.ts` names its re-exports one file at a time and does NOT name this
39
+ * one, so nothing here reaches the published surface. The package's sibling
40
+ * `@object-ui/data-objectstack` carries the same defect on its own ARRAY-form
41
+ * producer and CANNOT reach this module — `@object-ui/core`'s `exports` map has
42
+ * exactly one entry (`"."`), so a second package can only import what
43
+ * `src/index.ts` re-exports. Closing that half is a published-surface addition
44
+ * and a maintainer's call, not this module's; see objectui#9048 for the exact
45
+ * export it would need.
46
+ *
47
+ * ## Scope
48
+ *
49
+ * Only the case-insensitive contains operator, because only that operator is
50
+ * what the published table declares. The sibling positive operators
51
+ * (`$contains` / `$startsWith` / `$endsWith`) have no such row and keep the
52
+ * answer they have always given on every face — widening by analogy is the
53
+ * table's decision, not this module's.
54
+ */
55
+ /**
56
+ * A comparand as it appears INSIDE a refusal message.
57
+ *
58
+ * `JSON.stringify` alone is not safe here even though it is what the message
59
+ * wants: it THROWS on a BigInt and on a cyclic object. Both faces are hurt by
60
+ * that and in mirror-image ways, which is why the guard travels with the text
61
+ * rather than being re-derived per face. On the THROWING face the call sits
62
+ * inside a `throw new FilterOperatorError(...)` expression, so a `TypeError`
63
+ * raised while the message is being built escapes in the refusal's place, and
64
+ * `classifyLoadError` reads a bare `TypeError` as a network fault: the author
65
+ * would be told to check their connection about a filter this layer had already
66
+ * judged. On the EXCLUDE-AND-LOG face a refusal that throws while explaining
67
+ * itself turns the one path that stays quiet about a bad filter into the one
68
+ * path that takes the caller down.
69
+ *
70
+ * No JSON-sourced filter can carry either shape, so this is about the in-memory
71
+ * callers who hand a literal to a data source or to the converter.
72
+ * `?? String(target)` keeps `undefined` and a symbol readable — `JSON.stringify`
73
+ * returns `undefined` for both.
74
+ */
75
+ export declare function describeComparand(target: unknown): string;
76
+ /**
77
+ * THE CONTRACT HALF, now the spec's own (objectui#11073).
78
+ *
79
+ * `isRefusedTextComparand` (the discrimination: a non-string or empty
80
+ * comparand) and `textComparandRefusalReason` (the half of the message the
81
+ * contract owns, no face's envelope on it) were written here once, for both
82
+ * faces. `@objectstack/spec` 17.5.0 publishes the SAME two functions from
83
+ * `@objectstack/spec/data`, byte for byte the implementation this module carried
84
+ * — measured against the published `dist` when the bump landed, message text
85
+ * included. So they are re-exported rather than kept as a copy under the spec's
86
+ * own names, which `pnpm check:spec-symbols` refuses (a hand-written copy under
87
+ * a spec name is one release away from drifting). Both faces still import them
88
+ * from here, so the ONE-place property this module exists for is unchanged; the
89
+ * one place is now the spec. `describeComparand` above stays local: the spec
90
+ * does not export it.
91
+ */
92
+ export { isRefusedTextComparand, textComparandRefusalReason } from '@objectstack/spec/data';
@@ -0,0 +1,99 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * The ONE `icontains` comparand ruling this package owns, in one place
10
+ * (objectui#9048).
11
+ *
12
+ * ## Why this module exists rather than a third copy
13
+ *
14
+ * `@objectstack/spec`'s `FILTER_TEXT_CASES` carries two REJECTION rows for the
15
+ * case-insensitive contains operator — an empty comparand and a non-string one
16
+ * — each with `code: 'INVALID_FILTER'` and `mustMention: ['$icontains']`.
17
+ * Before this module the SAME discrimination and the SAME message were written
18
+ * out twice inside this package: `ValueDataSource`'s `refuseTextComparand`
19
+ * (objectui#8748, the reference implementation) and `filter-converter`'s
20
+ * (objectui#9001, whose own docblock calls itself "a PORT of a shipped,
21
+ * reviewed implementation"). objectui#9048's triage ruled on that directly:
22
+ *
23
+ * > three implementations of one refusal, of which one is correct ⇒ the useful
24
+ * > dispatch is not "add a third guard" but "make the dialects share the one
25
+ * > that works"
26
+ *
27
+ * So the contract's half — WHICH comparands are refused, and WHAT the author is
28
+ * told — lives here once, and each face keeps only its own ENVELOPE. That split
29
+ * is not a tidy-up: it is the half that does and the half that does not
30
+ * transfer, and both docblocks already said so before this module existed.
31
+ * `ValueDataSource` excludes-and-logs because it is deciding about one ROW and
32
+ * HAS a row to exclude; `filter-converter` is a PRODUCER deciding whether to
33
+ * send a query at all and has none, so it throws `FilterOperatorError`
34
+ * (`INVALID_FILTER` / 400).
35
+ *
36
+ * ## Not exported from `@object-ui/core`'s entry, deliberately
37
+ *
38
+ * `src/index.ts` names its re-exports one file at a time and does NOT name this
39
+ * one, so nothing here reaches the published surface. The package's sibling
40
+ * `@object-ui/data-objectstack` carries the same defect on its own ARRAY-form
41
+ * producer and CANNOT reach this module — `@object-ui/core`'s `exports` map has
42
+ * exactly one entry (`"."`), so a second package can only import what
43
+ * `src/index.ts` re-exports. Closing that half is a published-surface addition
44
+ * and a maintainer's call, not this module's; see objectui#9048 for the exact
45
+ * export it would need.
46
+ *
47
+ * ## Scope
48
+ *
49
+ * Only the case-insensitive contains operator, because only that operator is
50
+ * what the published table declares. The sibling positive operators
51
+ * (`$contains` / `$startsWith` / `$endsWith`) have no such row and keep the
52
+ * answer they have always given on every face — widening by analogy is the
53
+ * table's decision, not this module's.
54
+ */
55
+ /**
56
+ * A comparand as it appears INSIDE a refusal message.
57
+ *
58
+ * `JSON.stringify` alone is not safe here even though it is what the message
59
+ * wants: it THROWS on a BigInt and on a cyclic object. Both faces are hurt by
60
+ * that and in mirror-image ways, which is why the guard travels with the text
61
+ * rather than being re-derived per face. On the THROWING face the call sits
62
+ * inside a `throw new FilterOperatorError(...)` expression, so a `TypeError`
63
+ * raised while the message is being built escapes in the refusal's place, and
64
+ * `classifyLoadError` reads a bare `TypeError` as a network fault: the author
65
+ * would be told to check their connection about a filter this layer had already
66
+ * judged. On the EXCLUDE-AND-LOG face a refusal that throws while explaining
67
+ * itself turns the one path that stays quiet about a bad filter into the one
68
+ * path that takes the caller down.
69
+ *
70
+ * No JSON-sourced filter can carry either shape, so this is about the in-memory
71
+ * callers who hand a literal to a data source or to the converter.
72
+ * `?? String(target)` keeps `undefined` and a symbol readable — `JSON.stringify`
73
+ * returns `undefined` for both.
74
+ */
75
+ export function describeComparand(target) {
76
+ try {
77
+ return JSON.stringify(target) ?? String(target);
78
+ }
79
+ catch {
80
+ return String(target);
81
+ }
82
+ }
83
+ /**
84
+ * THE CONTRACT HALF, now the spec's own (objectui#11073).
85
+ *
86
+ * `isRefusedTextComparand` (the discrimination: a non-string or empty
87
+ * comparand) and `textComparandRefusalReason` (the half of the message the
88
+ * contract owns, no face's envelope on it) were written here once, for both
89
+ * faces. `@objectstack/spec` 17.5.0 publishes the SAME two functions from
90
+ * `@objectstack/spec/data`, byte for byte the implementation this module carried
91
+ * — measured against the published `dist` when the bump landed, message text
92
+ * included. So they are re-exported rather than kept as a copy under the spec's
93
+ * own names, which `pnpm check:spec-symbols` refuses (a hand-written copy under
94
+ * a spec name is one release away from drifting). Both faces still import them
95
+ * from here, so the ONE-place property this module exists for is unchanged; the
96
+ * one place is now the spec. `describeComparand` above stays local: the spec
97
+ * does not export it.
98
+ */
99
+ export { isRefusedTextComparand, textComparandRefusalReason } from '@objectstack/spec/data';