@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
@@ -6,12 +6,39 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { liftLegacyGlobalFilterDefault } from '@object-ui/types';
9
- import { DATE_RANGE_PRESETS } from '@objectstack/spec/ui';
9
+ import { DATE_RANGE_PRESETS, DashboardSchema as SpecDashboardSchema } from '@objectstack/spec/ui';
10
10
  import { resolveDateMacros } from './date-macros.js';
11
11
  /** Reserved filter name for the dashboard's built-in date range. */
12
12
  export const DATE_RANGE_FILTER_NAME = 'dateRange';
13
13
  /** Default target field for the built-in date range filter. */
14
14
  const DATE_RANGE_DEFAULT_FIELD = 'created_at';
15
+ /**
16
+ * The preset an authored `dateRange` resolves to when it omits `defaultRange`
17
+ * (objectui#10339) — READ FROM THE SPEC, never restated here.
18
+ *
19
+ * `@objectstack/spec` declares `DashboardSchema.dateRange.defaultRange` with a
20
+ * `.default(...)`, so the platform's parse of `dateRange: { field: 'created_at' }`
21
+ * carries that preset, and a renderer that treated the omission as "no filter"
22
+ * showed an UNFILTERED dashboard for a document the protocol says is filtered
23
+ * (#7759 ruling 5617465269, rule 1: the read site implements every arm the spec
24
+ * declares). The value is derived by parsing an EMPTY `dateRange` element
25
+ * through the spec's own schema: the only keys that come back are the ones the
26
+ * spec supplies by default, so this reads the spec's answer rather than a
27
+ * hand-copied string that could drift from it. `allowCustomRange` is the
28
+ * sibling default and needs no such read — it is consumed as `!== false`,
29
+ * which already is its spec default.
30
+ *
31
+ * Computed on first use and cached: `DashboardSchema` is a lazy schema, and
32
+ * forcing its construction at module load would cost every importer of
33
+ * `@object-ui/core` for a value only a dashboard with a bare `dateRange` needs.
34
+ */
35
+ let specDateRangeDefault;
36
+ function specDefaultDateRangePreset() {
37
+ if (specDateRangeDefault === undefined) {
38
+ specDateRangeDefault = SpecDashboardSchema.shape.dateRange.parse({})?.defaultRange;
39
+ }
40
+ return specDateRangeDefault;
41
+ }
15
42
  /**
16
43
  * Date-range presets → date-macro token bounds. Tokens stay symbolic in the
17
44
  * generated condition; every widget renderer resolves them at query time via
@@ -72,7 +99,7 @@ function presetBounds(preset) {
72
99
  * protect.
73
100
  *
74
101
  * What stays local is the BOUNDS table above, which is the other half of
75
- * #4614's design ("the two vocabularies live one import apart and neither
102
+ * objectstack-ai/objectstack#4614's design ("the two vocabularies live one import apart and neither
76
103
  * restates the other's grammar"): the spec owns which presets exist, this
77
104
  * module owns what each one resolves to in date-macro tokens.
78
105
  */
@@ -111,13 +138,13 @@ function warnDateFilter(message) {
111
138
  console.warn(`[dashboard-filters] ${message}`);
112
139
  }
113
140
  /**
114
- * Dev-mode gate, matching `actions/actionKeys.ts` — a deprecation warning that
115
- * floods a production console is a warning that gets muted.
141
+ * Dev-mode gate, matching `actions/actionKeys.ts` — a warning that floods a
142
+ * production console is a warning that gets muted.
116
143
  */
117
144
  const isDev = () => globalThis.process?.env?.NODE_ENV !==
118
145
  'production';
119
146
  /**
120
- * Warn-once memo for the bare-string `options` shorthand (objectui#4356).
147
+ * Warn-once memo for the DROPPED bare-string `options` members (objectui#4356).
121
148
  *
122
149
  * Keyed by filter NAME **and** the offending values, deliberately — the same
123
150
  * reasoning `warnOnUnknownActionKeys` records for its own memo. Keying on the
@@ -132,7 +159,13 @@ const isDev = () => globalThis.process?.env?.NODE_ENV !==
132
159
  * flood the dedupe exists to prevent.
133
160
  */
134
161
  const warnedShorthandOptions = new Set();
135
- /** Reset the shorthand-options warn-once memo. Exported for tests. */
162
+ /**
163
+ * Reset the dropped-shorthand warn-once memo. Exported for tests.
164
+ *
165
+ * Kept through the objectui#4356 retirement: the memo it clears outlived the
166
+ * deprecation warning, because the warning that replaced it (a member was
167
+ * DROPPED, not lifted) dedupes on the same key for the same render-path reason.
168
+ */
136
169
  export function resetDashboardFilterWarnings() {
137
170
  warnedShorthandOptions.clear();
138
171
  }
@@ -252,45 +285,40 @@ function normalizeDateDefault(type, defaultValue) {
252
285
  }
253
286
  /**
254
287
  * Normalize a filter's static `options` declaration to `{ value, label }`
255
- * pairs. The @objectstack/spec `GlobalFilterSchema.options` form is
256
- * `{ value, label }` objects; the bare-string shorthand (`options: ['EMEA', …]`)
257
- * is still lifted, but is DEPRECATED and now says so out loud. Rendering an
258
- * un-normalized option crashes React — this is the single place both shapes
259
- * converge.
288
+ * pairs — the @objectstack/spec `GlobalFilterSchema.options` form, and the
289
+ * only form this function emits. Rendering an un-normalized option crashes
290
+ * React, so every member that reaches a renderer passes through here.
260
291
  *
261
- * ## The shorthand's deprecation (objectui#4356, objectstack#7917)
292
+ * ## The bare-string shorthand is RETIRED (objectui#4356, objectstack#7917)
262
293
  *
263
294
  * Maintainer ruling of 2026-08-12 on objectstack#7917, verbatim 「7917 ②」:
264
- * option ② — **the spec stays strict; the runtime bare-string lift retires
265
- * behind a deprecation window sized by a stored-dashboard survey.** So a
266
- * document spelling `options: ['EMEA']` renders here and is refused the moment
267
- * it reaches the platform's validation — the "one strict contract beats N
268
- * dialects" divergence AGENTS.md #0.1 names, with the renderer's tolerance
269
- * acting as a second de-facto contract.
270
- *
271
- * This is the WARN half of that window (Phase 1). The lift itself is unchanged
272
- * and remains mechanically lossless (`'EMEA'` → `{ value: 'EMEA', label:
273
- * 'EMEA' }`), because stored dashboards carry the shorthand and dropping it
274
- * silently would turn a rendering filter into an empty one. Removal (Phase 2)
275
- * is scheduled on objectstack#7917, earliest one minor release after this ships
276
- * and not before the live-tenant channel has actually been queried.
277
- *
278
- * The warning is not decoration: a silent lift can never be retired, because
279
- * nothing would ever show that the last shorthand document is gone (ADR-0078 —
280
- * nothing silently inert). It is the same reasoning `liftLegacyFilterDeclaration`
281
- * records above, for the sibling alias.
282
- *
283
- * Phase 0 shipped in the same PR: objectui's own docs, its `plugin-dashboard`
284
- * README and its schema-catalog corpus stopped TEACHING the shorthand, so the
285
- * stored population is no longer growing while this warning asks authors to
286
- * migrate. Warning authors while the docs still taught the form would have been
287
- * a contradiction users report as a bug.
295
+ * option ② — **the spec stays strict; the runtime bare-string lift retires.**
296
+ * The window closed on the maintainer's 2026-09-02 ruling, verbatim
297
+ * 「objectstack#7917 不考虑现有数据」: Phase 2 proceeds on release cadence
298
+ * alone, with no live-tenant survey and no migration entry.
299
+ *
300
+ * So `options: ['EMEA']` — which the Phase 1 build (PR #4601) lifted to
301
+ * `{ value: 'EMEA', label: 'EMEA' }` under a deprecation warning — now yields
302
+ * NO option. A member that is not an object is skipped, exactly as a nullish
303
+ * member or an object with no `value` already was: a shorthand-only filter
304
+ * resolves with no `options`, and a mixed array keeps only its object members.
305
+ * The runtime reads the document the way the spec does, which is the end
306
+ * state option ② named — one strict contract, no renderer dialect beside it
307
+ * (AGENTS.md #0.1). ⛔ No lift, alias, fallback or migration returns here.
308
+ *
309
+ * The skip is not silent. `warnDroppedShorthandOptions` names the filter, the
310
+ * dropped members and the rewrite, once per filter per session — ADR-0078 §4:
311
+ * where the runtime skips an instance it emits a dev-mode diagnostic rather
312
+ * than swallowing it, and a STORED dashboard is the one document no author-time
313
+ * gate ever re-reads, so an empty select with nothing in the console would read
314
+ * as "no data". What that warning must NOT do is promise a lift: the Phase 1
315
+ * text said "still lifted here", and that sentence retired with the arm.
288
316
  *
289
317
  * ## What is normalized, and what is deliberately NOT (objectui#4032 / #4163)
290
318
  *
291
- * The PAIR SHAPE is normalized (`value` stringified, a bare string lifted to a
292
- * pair). The LABEL's authoring vocabulary is carried through untouched, because
293
- * `label` is `I18nLabel` — a string OR an inline per-locale map.
319
+ * The PAIR SHAPE is normalized (`value` stringified). The LABEL's authoring
320
+ * vocabulary is carried through untouched, because `label` is `I18nLabel` — a
321
+ * string OR an inline per-locale map.
294
322
  *
295
323
  * This line used to read:
296
324
  *
@@ -312,8 +340,8 @@ function normalizeFilterOptions(options, filterName) {
312
340
  if (!Array.isArray(options) || options.length === 0)
313
341
  return undefined;
314
342
  const normalized = [];
315
- /** Every bare-string member, in authored order — one warning names them all. */
316
- const shorthand = [];
343
+ /** Every non-object member, in authored order — one warning names them all. */
344
+ const dropped = [];
317
345
  for (const o of options) {
318
346
  if (o === null || o === undefined)
319
347
  continue;
@@ -329,39 +357,48 @@ function normalizeFilterOptions(options, filterName) {
329
357
  });
330
358
  }
331
359
  else {
332
- shorthand.push(String(o));
333
- normalized.push({ value: String(o), label: String(o) });
360
+ // The retired shorthand arm (objectui#4356): nothing is pushed for this
361
+ // member. It is recorded for the warning and yields no option.
362
+ dropped.push(o);
334
363
  }
335
364
  }
336
- if (shorthand.length > 0)
337
- warnShorthandOptions(filterName, shorthand);
365
+ if (dropped.length > 0)
366
+ warnDroppedShorthandOptions(filterName, dropped);
338
367
  return normalized.length > 0 ? normalized : undefined;
339
368
  }
340
369
  /**
341
- * Say the deprecated shorthand out loud — once per offending filter per
342
- * session, naming the filter and printing the canonical replacement.
370
+ * Say the dropped members out loud — once per offending filter per session,
371
+ * naming the filter, the members that yielded no option, and the rewrite.
343
372
  *
344
- * Collected per FILTER rather than per option: a filter declaring
373
+ * Collected per FILTER rather than per member: a filter declaring
345
374
  * `['EMEA', 'APAC', 'AMER']` is one authoring mistake in one place, so it earns
346
375
  * one warning carrying all three values, not three warnings the author has to
347
376
  * reassemble. A MIXED array (`[{ value: 'won', … }, 'lost']`) names only the
348
- * bare members, which are the ones that need rewriting — partial migrations
349
- * happen and a warning that re-reports the already-canonical members is noise.
377
+ * dropped members — the object members rendered, and re-reporting them is noise.
378
+ *
379
+ * Dev-mode only and memoised, exactly as the Phase 1 deprecation warning was:
380
+ * `resolveDashboardFilterDefs` runs on every dashboard render, and a warning
381
+ * that floods a console is a warning that gets muted. What changed is the
382
+ * sentence — this one reports a member that was DROPPED and promises no lift.
350
383
  */
351
- function warnShorthandOptions(filterName, shorthand) {
384
+ function warnDroppedShorthandOptions(filterName, dropped) {
352
385
  if (!isDev())
353
386
  return;
354
- const memo = `${filterName}:${shorthand.join(',')}`;
387
+ // A string is quoted so `"42"` and `42` stay distinguishable; anything else is
388
+ // spelled as itself — `String()` rather than `JSON.stringify()`, because a
389
+ // programmatic caller can hand this a value JSON cannot carry.
390
+ const spelled = dropped.map((v) => (typeof v === 'string' ? JSON.stringify(v) : String(v)));
391
+ const memo = `${filterName}:${spelled.join(',')}`;
355
392
  if (warnedShorthandOptions.has(memo))
356
393
  return;
357
394
  warnedShorthandOptions.add(memo);
358
- const canonical = shorthand.map((v) => `{ value: ${JSON.stringify(v)}, label: ${JSON.stringify(v)} }`).join(', ');
359
- warnDateFilter(`filter "${filterName}": \`options\` carries the bare-string shorthand ` +
360
- `(${shorthand.map((v) => JSON.stringify(v)).join(', ')}), which @objectstack/spec's ` +
361
- `\`GlobalFilterSchema\` REFUSES at publish — a dashboard authored this way renders here ` +
362
- `and is rejected the moment it reaches the platform (objectui#4356). Rewrite the stored ` +
363
- `dashboard to the canonical pair form: [${canonical}]. Still lifted here for already-` +
364
- `persisted dashboards; the lift is removed on the objectstack#7917 schedule.`);
395
+ const canonical = dropped
396
+ .map((v) => `{ value: ${JSON.stringify(String(v))}, label: ${JSON.stringify(String(v))} }`)
397
+ .join(', ');
398
+ warnDateFilter(`filter "${filterName}": \`options\` members ${spelled.join(', ')} are not \`{ value, label }\` ` +
399
+ `objects and were DROPPED — the bare-string shorthand is no longer lifted (objectui#4356), so the ` +
400
+ `filter renders without them. Rewrite the stored dashboard to @objectstack/spec's pair form: ` +
401
+ `[${canonical}].`);
365
402
  }
366
403
  /**
367
404
  * Normalize a dashboard schema's filter declarations into a flat list of
@@ -372,7 +409,11 @@ function warnShorthandOptions(filterName, shorthand) {
372
409
  export function resolveDashboardFilterDefs(schema) {
373
410
  const byName = new Map();
374
411
  if (schema.dateRange) {
375
- const preset = schema.dateRange.defaultRange;
412
+ // An omitted `defaultRange` takes the spec's declared default (#10339); an
413
+ // explicit value — `'custom'` included — is used exactly as authored.
414
+ const preset = schema.dateRange.defaultRange === undefined
415
+ ? specDefaultDateRangePreset()
416
+ : schema.dateRange.defaultRange;
376
417
  byName.set(DATE_RANGE_FILTER_NAME, {
377
418
  name: DATE_RANGE_FILTER_NAME,
378
419
  field: schema.dateRange.field || DATE_RANGE_DEFAULT_FIELD,
@@ -398,9 +439,14 @@ export function resolveDashboardFilterDefs(schema) {
398
439
  byName.set(name, {
399
440
  name,
400
441
  field: f.field,
442
+ // Spread rather than assigned (`061f5e829`): every filter authored
443
+ // before this key existed keeps the exact key set it resolved to, so
444
+ // `'object' in def` stays the discriminator the render side branches on
445
+ // and no consumer sees a new own-property carrying `undefined`.
446
+ ...(typeof f.object === 'string' && f.object ? { object: f.object } : {}),
401
447
  label: f.label,
402
448
  type,
403
- // `name` is the identifying context the deprecation warning needs, and
449
+ // `name` is the identifying context the dropped-member warning needs, and
404
450
  // the local above already resolved it — nothing new is threaded through a
405
451
  // public signature for it. `normalizeFilterOptions` is module-private, so
406
452
  // widening ITS parameter list is not a contract move.
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import type { AnalyticsResult } from '@objectstack/spec/contracts';
19
19
  import type { PercentScale } from '@objectstack/spec/data';
20
+ import { type BuiltinAggregateLabels } from './chart-series.js';
20
21
  /**
21
22
  * Column metadata the analytics server returns alongside the rows — the spec's
22
23
  * `AnalyticsResult.fields[]` element BY REFERENCE, never a local restatement of
@@ -59,28 +60,35 @@ export type DatasetResultField = AnalyticsResult['fields'][number];
59
60
  */
60
61
  export type { PercentScale } from '@objectstack/spec/data';
61
62
  /**
62
- * Scale a stored `percent`-field value to its DISPLAY magnitude.
63
- *
64
- * Percent fields store a FRACTION (0–1) by convention — a stored `0.75` means
65
- * 75% (see the percent edit widget `PercentField`, which divides input by 100).
66
- * A value already in whole-percent form (magnitude ≥ 1, e.g. a `progress` /
67
- * `completion` field storing `57`) is passed through unchanged. This is the
68
- * SINGLE source of truth for percent display scaling, shared by the list-view
69
- * percent cell renderer (`formatPercent` in `@object-ui/fields`) and the dataset
70
- * measure formatter ({@link formatMeasure}) so a percent renders identically as
71
- * a row value and as an aggregated metric — the two surfaces can never drift.
72
- *
73
- * ⚠️ That last sentence was briefly FALSE, and objectui#4576 is what made it
74
- * true again. Sharing the SCALING was never enough on its own: between #4553
75
- * and #4576 the cell renderer got the locale's percent CONVENTION from `Intl`
76
- * while {@link formatMeasure} appended a literal '%', so a German session read
77
- * `1.234,5 %` in a list cell and `1.234,5%` in a dashboard measure — the same
78
- * number, scaled identically, rendered under two conventions. Both ends now go
79
- * through the locale's own percent affix. If a third surface ever needs percent
80
- * display, it takes BOTH halves from here — the scaling AND the convention —
81
- * or this promise breaks again in the same place.
63
+ * Scale a stored percentage to its DISPLAY magnitude (percentage points), at
64
+ * the storage the caller STATES.
65
+ *
66
+ * `percentScale` is the spec's `PercentScale`: `fraction` is a 0–1 ratio
67
+ * (`0.75` ⇒ 75, `1` ⇒ 100), `whole` is already in percentage points (`75` ⇒
68
+ * 75, `1` ⇒ 1). A caller holding a field definition reads it with the spec's
69
+ * `percentScaleOf` (`@objectstack/spec/data`): a `percent` field stores a
70
+ * fraction unless it declares a `max` above 1. A caller with no field (a
71
+ * computed ratio, a server measure column) states the storage it knows: a
72
+ * `derived: { op: 'ratio' }` measure is a fraction by definition, and the
73
+ * server annotates a measure column with its `percentScale`.
74
+ *
75
+ * ⛔ No storage is inferred from the VALUE any more (objectui#11475). This
76
+ * function used to be `value > -1 && value < 1 ? value * 100 : value`, a guess
77
+ * from the magnitude that read neither the field nor its `max`. A fraction-
78
+ * stored `1` (100%) therefore read `1%` in the list cell while the read-only
79
+ * form, which reads the declaration, read `100%`; a whole-stored `0.5` read
80
+ * `50%`. objectui#3136 was the same guess on a dashboard measure. The
81
+ * parameter is REQUIRED, so every caller says which storage it holds, and a
82
+ * value outside the union throws rather than falling to a branch silently.
83
+ *
84
+ * ⭐ The contract a third percent surface still owes is BOTH halves: this
85
+ * scaling, at the stored scale, AND the locale's percent convention
86
+ * (`style: 'percentPoints'`, see {@link formatMeasure} and `formatPercent` in
87
+ * `@object-ui/fields`). Between objectui#4553 and objectui#4576 the two ends
88
+ * shared the scaling and not the convention, so a German session read
89
+ * `1.234,5 %` in a list cell and `1.234,5%` in a dashboard measure.
82
90
  */
83
- export declare function percentDisplayValue(value: number): number;
91
+ export declare function percentDisplayValue(value: number, percentScale: PercentScale): number;
84
92
  /**
85
93
  * Format a MEASURE value. Currency comes from the field's declared `currency`
86
94
  * (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
@@ -118,8 +126,54 @@ export declare function percentDisplayValue(value: number): number;
118
126
  * is pure, so it moved DOWN into this package (`./number-display.ts`) and
119
127
  * `@object-ui/i18n` re-exports it. The duplicate implementation this file used
120
128
  * to carry is gone, and with it the drift that duplicate produced — see the
121
- * percent note in the body, and {@link percentDisplayValue}'s promise, which
122
- * is true again.
129
+ * percent note in the body, and the both-halves contract
130
+ * {@link percentDisplayValue}'s doc comment states.
131
+ *
132
+ * ── Date-valued measures (objectui#7178) ──
133
+ * `min` / `max` over a date or datetime field is a legitimate measure, and it
134
+ * used to render its stored value verbatim: the guard above returned
135
+ * `String(v)` for anything non-numeric BEFORE `format` was read, so a KPI tile
136
+ * showed `2026-07-04T07:00:00.000Z` in `text-2xl` and wrapped it over two
137
+ * lines. A date-shaped value now routes through {@link formatMeasureDate} to
138
+ * the display path list cells use; nothing about the numeric path moved.
139
+ *
140
+ * ⚠️ What `format` can and cannot say for those values, measured rather than
141
+ * assumed, and stated PER ARM — a date-shaped measure takes one of two arms in
142
+ * {@link formatMeasureDate}, and this paragraph used to describe only one of
143
+ * them while reading as though it described both. That cost a card: an
144
+ * objectui triage pass read the undifferentiated claim, concluded the defect
145
+ * was already fixed, and downgraded objectui#8352, which a driven browser run
146
+ * then refuted. The two arms agree TODAY, but only because #8352 made them; a
147
+ * universal sentence here is what hid the disagreement before, so it is not
148
+ * coming back even now that it would be true.
149
+ *
150
+ * The shared date path takes a named STYLE, not a date pattern. On BOTH arms
151
+ * the honoured vocabulary is `'short'` and `'relative'`:
152
+ *
153
+ * - `Field.date` (`2026-09-08`) — honoured by threading `format` into
154
+ * `formatDate`'s style parameter. The same words `DateCellRenderer`
155
+ * honours from `field.format` for the same field.
156
+ * - `Field.datetime` (`2026-09-01T00:00:00.000Z`) — honoured by SELECTING a
157
+ * formatter, because `formatDateTime` has no style parameter to thread
158
+ * into (objectui#8352). Before that card this arm honoured NOTHING: it
159
+ * dropped `format` entirely, so `'relative'` rendered the absolute face.
160
+ *
161
+ * Every other string falls to that arm's default face — a PATTERN like
162
+ * `format: 'YYYY-MM-DD'` is accepted by the schema, reaches this function, and
163
+ * renders the locale default, and so does `'compact'`, which is
164
+ * `formatDateTime`'s own vocabulary but not this path's. The pattern part is
165
+ * not new behaviour introduced here: `plugin-dashboard`'s `recordFields`
166
+ * already routes a date-shaped `format` into the same style slot and gets the
167
+ * same locale default. Closing it would mean teaching the shared path a
168
+ * pattern grammar, which is a change to the path itself and to every list cell
169
+ * that reads it — not a measure concern, and not either card.
170
+ *
171
+ * ⚠️ `'relative'` renders the ABSOLUTE face for a value more than ±7 days from
172
+ * today, on both arms, by `formatRelativeDate`'s own design. That is correct
173
+ * output, not a regression — but it also means an out-of-window value is
174
+ * useless for testing this path, because it renders identically whether
175
+ * `format` is honoured or dropped. Both readings that missed #8352 were taken
176
+ * on 40–60-day-old data.
123
177
  */
124
178
  export declare function formatMeasure(v: unknown, format?: string, currency?: string, percentScale?: PercentScale, locale?: string): string;
125
179
  /**
@@ -140,13 +194,31 @@ export declare function formatDimensionValue(v: unknown, locale?: string): strin
140
194
  *
141
195
  * `fieldLabel` is injected (rather than imported) so this stays React/i18n-free;
142
196
  * callers pass `useSafeFieldLabel().fieldLabel`.
197
+ *
198
+ * ## `builtinAggregateLabels` (objectui#7534, sibling of #7258)
199
+ *
200
+ * A result field the analytics service minted as a BUILT-IN default measure
201
+ * carries `builtinAggregate` (objectstack#14492) and a hard-coded English
202
+ * `label` ('Count'). {@link resolveMeasureLabel} is the ONE resolver for that —
203
+ * #7258 wired it into `buildChartSeries()`, so a chart legend already read
204
+ * `计数` on a zh console while the table underneath it, the KPI caption, the
205
+ * pivot header and the dataset preview all still said `Count`, because they
206
+ * resolve their titles through here instead.
207
+ *
208
+ * Passing the caller's resolved labels closes that gap without a second
209
+ * resolution order: the discriminator is consulted FIRST (exactly as on the
210
+ * chart seam), the existing `fieldLabel` convention still applies on top, and
211
+ * the raw name is still the floor. Omitting the argument reproduces the
212
+ * previous output byte for byte — an author-declared measure carries no
213
+ * discriminator and keeps its wire `label` verbatim (objectui#4106), and a
214
+ * provider-less host resolves no labels and so changes nothing.
143
215
  */
144
- export declare function buildDatasetFieldHelpers(fields: DatasetResultField[] | undefined, object: string | undefined, fieldLabel?: (objectName: string, fieldName: string, fallback: string) => string): {
216
+ export declare function buildDatasetFieldHelpers(fields: DatasetResultField[] | undefined, object: string | undefined, fieldLabel?: (objectName: string, fieldName: string, fallback: string) => string, builtinAggregateLabels?: BuiltinAggregateLabels): {
145
217
  measureField: (name: string) => DatasetResultField | undefined;
146
218
  headerLabel: (name: string) => string;
147
219
  };
148
220
  /**
149
- * A half-open date-range drill scope for one time-bucketed dimension (#1752):
221
+ * A half-open date-range drill scope for one time-bucketed dimension (objectstack-ai/objectstack#1752):
150
222
  * the object FIELD to filter and its inclusive `gte` / exclusive `lt` bounds
151
223
  * (the server's `drillRanges` sidecar entry).
152
224
  */
@@ -161,18 +233,127 @@ export interface DatasetDrillRange {
161
233
  * Each drillable dimension maps to its underlying object field, filtered by the
162
234
  * dimension's RAW grouped value (from the server's parallel `drillRawRows`, NOT
163
235
  * the visible row which carries the display LABEL — a select/lookup label would
164
- * mis-filter). An empty/undefined raw value normalizes to `null` (an explicit
165
- * "is empty" filter). The render-time `runtimeFilter` is ANDed in so the drilled
166
- * list stays within the same slice the aggregate was computed over.
236
+ * mis-filter). The render-time `runtimeFilter` is ANDed in so the drilled list
237
+ * stays within the same slice the aggregate was computed over.
238
+ *
239
+ * ## What the EMPTY bucket means, and why it is spelled `{ $null: true }`
167
240
  *
168
- * A time-bucketed date dimension (#1752) drills by RANGE, not equality — a
241
+ * A drill must return exactly the rows the clicked bucket COUNTED. The empty
242
+ * bucket is the one the aggregate grouped rows with NO VALUE for that dimension
243
+ * into, so the drill means "this dimension has no value" — NOT "this dimension
244
+ * holds the literal value null". Those are different row sets, and the
245
+ * difference is measurable rather than theoretical: against `ValueDataSource`'s
246
+ * matcher over rows `{owner:'alice'}` / `{owner:null}` / `{}` (no `owner` key),
247
+ * `['owner','is_null',true]` selects the explicit-null row AND the missing-key
248
+ * row, while an equality test against `null` selects only the explicit-null one
249
+ * and drops the row that has no `owner` at all. The bucket counted both, so
250
+ * is-null is the one that agrees with the number the user clicked.
251
+ *
252
+ * ⇒ every empty authoring — `''`, `null` and `undefined` alike — becomes ONE
253
+ * spelling, `{ [field]: { $null: true } }`, which `convertFiltersToAST` lowers
254
+ * to `[field, 'is_null', true]`. The three are not told apart because the
255
+ * server already merged them into the single bucket the user clicked; there is
256
+ * no second bucket here for a second spelling to address.
257
+ *
258
+ * ⚠️ This value used to be a bare `null`, and that is the defect objectui#9085
259
+ * records: `convertFiltersToAST`'s OLDEST pinned behaviour is to SKIP a key
260
+ * whose value is `null` / `undefined` (pinned as "should skip null and
261
+ * undefined values"), so the constraint was dropped on the way to the wire and
262
+ * the empty-bucket drill answered with a SUPERSET — every row, silently. The
263
+ * repair is here at the PRODUCER and ⛔ not at that converter: the skip is
264
+ * relied on by other producers, and changing it would move every caller's
265
+ * meaning at once (objectui#9020 ruled it stays). `$null` is not a new
266
+ * vocabulary either — it is a declared spec operator this dialect already
267
+ * lowers, and the one spelling that says the same thing on BOTH of
268
+ * `ValueDataSource`'s routes, where a bare `null` said "only an explicit null"
269
+ * on the plain-object route and "no constraint at all" on the AST route.
270
+ *
271
+ * ⚠️ Known BOUNDARY, unchanged by this and deliberately not widened: the drill
272
+ * "escape hatch" (the host's `openRecordList`, which serializes a drill filter
273
+ * into `filter[...]` URL params) has NO spelling for is-null — its operator
274
+ * vocabulary is equality plus four range bounds — so it drops this condition
275
+ * exactly as it already dropped the bare `null`, byte-for-byte the same query
276
+ * string. That surface is a superset today and stays one; closing it needs a
277
+ * URL-dialect operator on both the write and the read side, which is its own
278
+ * card.
279
+ *
280
+ * A time-bucketed date dimension (objectstack-ai/objectstack#1752) drills by RANGE, not equality — a
169
281
  * humanized bucket ("2026-Q2") can't be exact-matched, so the server sends a
170
282
  * half-open `[gte, lt)` per date dim in `rawRanges` instead of a raw value.
171
283
  * Each becomes an ObjectQL range operator object (`{ $gte, $lt }`) so the drill
172
284
  * scopes the list to the clicked time bucket instead of every bucket (which the
173
285
  * old date-dim skip degraded to — a superset).
174
286
  *
287
+ * ## The widget filter is CONJOINED, not spread (objectui#9137)
288
+ *
289
+ * ⛔ `runtimeFilter` is composed through {@link composeDrillFilter}, never by
290
+ * spreading it into the object literal below. Spreading is correct only for the
291
+ * OBJECT arm; this parameter's declared producer sends the ARRAY arm. The
292
+ * dashboard's `DashboardWidgetSchema.filter` docblock states it outright —
293
+ * "objectui passes an ObjectQL FilterNode array here, not the spec's
294
+ * `FilterCondition` envelope" — and `DatasetWidget`'s guard admits it
295
+ * (`typeof [] === 'object'` and `Object.keys(['x']).length === 1` are both
296
+ * true), so the array arm reached this line unfiltered. Spreading an ARRAY
297
+ * yields INDEX keys: `[['region','=','emea']]` became
298
+ * `{ '0': ['region','=','emea'] }`, which is not a condition anyone can honour.
299
+ *
300
+ * ⚠️ MEASURED, and it is NOT the silent superset the sibling sites had. An
301
+ * index key is not dropped on the way to the wire — every sink in circulation
302
+ * refuses it LOUDLY, because a bare array is not a legal equality comparand
303
+ * (objectui#8530 / objectui#8514): `convertFiltersToAST` THROWS
304
+ * `FilterOperatorError`, `driver-sql` answers `400 INVALID_FILTER`, and
305
+ * `ValueDataSource`'s matcher selects NOTHING. Against rows scoped
306
+ * `region = 'emea'`, drilling the `stage = 'won'` bucket returned `(none)`
307
+ * where the widget alone returned three rows and the conjunction returns the
308
+ * right two. So the widget's conditions were not merely lost — the drill was
309
+ * dead in the array arm. The failure is fail-CLOSED here, and this is the third
310
+ * site of the class objectui#8944 (chart) and objectui#9024 (pivot) removed.
311
+ *
312
+ * Three properties of THIS site, each confirmed rather than assumed — it is not
313
+ * a copy-paste of the other two:
314
+ *
315
+ * 1. The return stays a NON-optional `Record<string, unknown>`, because callers
316
+ * do not expect `undefined`. `composeDrillFilter` answers `undefined` when
317
+ * both sources are empty, so that one case is spelled `?? {}` — the same
318
+ * empty object this function already returned for a bucket with no dims.
319
+ * 2. Range operators SURVIVE. A time-bucketed dim contributes
320
+ * `{ $gte, $lt }`, and the composition re-spells it as two `$and` children
321
+ * on the same field (`[{d:{$gte}},{d:{$lt}}]`) rather than losing a bound;
322
+ * the selected rows are identical, and `serializeDrillFilterParams` walks
323
+ * `$and` (objectui#8944) so both bounds still reach `filter[d][gte|lt]`.
324
+ * 3. `runtimeFilter` is declared `unknown`, which is what the two consumers
325
+ * actually hand it — `any` from the dashboard, `Record<string, unknown>`
326
+ * from `DatasetReportRenderer`. Widening the PARAMETER is what admits both
327
+ * without a cast; ⛔ neither consumer's own type is changed by this.
328
+ *
329
+ * ⚠️ Conjunction changes one behaviour deliberately: a field named by BOTH the
330
+ * widget filter and the clicked bucket used to be overwritten by the bucket,
331
+ * and now both conditions apply. That is the point — a drill may narrow the
332
+ * widget's scope and may never widen it — and it is the posture the chart and
333
+ * pivot sites already ship.
334
+ *
335
+ * ## ⭐ Only the COMPOSING leg moves; the identity leg is left byte-identical
336
+ *
337
+ * The sink is asked only when there IS a second source. That is this site's
338
+ * one departure from `ObjectChart` / `ObjectPivotTable`, which call it
339
+ * unconditionally, and it is deliberate rather than a shortcut: the spread
340
+ * this replaces only ever ran when `runtimeFilter` was present, so the
341
+ * no-widget-filter path carries no defect to repair.
342
+ *
343
+ * ⚠️ Routing it anyway was MEASURED and rejected. `composeDrillFilter`'s own
344
+ * note — "a lone surviving source lowers back to exactly the flat object the
345
+ * spread produced" — holds only for a source of ONE condition; with two it
346
+ * lowers to `$and`, because `convertFiltersToAST` emits one node per condition
347
+ * and `parseFilterAST` cannot fold two nodes back onto one key. A `{ $gte,
348
+ * $lt }` range is two conditions on its own, so even a single date bucket
349
+ * re-spells. Sending the identity leg through the sink therefore re-shaped
350
+ * every multi-condition dataset drill — including the controls objectui#9085,
351
+ * objectui#4056, objectstack#5473 and objectstack-ai/objectstack#1752 pin on paths this card does not
352
+ * touch — for ZERO change in the rows selected or the `filter[...]` params
353
+ * emitted. ⛔ Do not "simplify" this to an unconditional call without redoing
354
+ * that measurement.
355
+ *
175
356
  * Shared by the dashboard `DatasetWidget` and the report renderer so a drill
176
357
  * filters identically (and correctly, including lookups) on both surfaces.
177
358
  */
178
- export declare function buildDatasetDrillFilter(rawRow: Record<string, unknown> | undefined, drillDims: string[], dimensionFields: Record<string, string>, runtimeFilter?: Record<string, unknown>, rawRanges?: Record<string, DatasetDrillRange>): Record<string, unknown>;
359
+ export declare function buildDatasetDrillFilter(rawRow: Record<string, unknown> | undefined, drillDims: string[], dimensionFields: Record<string, string>, runtimeFilter?: unknown, rawRanges?: Record<string, DatasetDrillRange>): Record<string, unknown>;