@object-ui/core 17.2.0 → 17.3.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 (43) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +29 -0
  3. package/dist/actions/ActionEngine.d.ts +11 -6
  4. package/dist/actions/ActionEngine.js +26 -19
  5. package/dist/actions/ActionRunner.d.ts +16 -3
  6. package/dist/actions/ActionRunner.js +2 -1
  7. package/dist/actions/actionErrorDetail.d.ts +26 -0
  8. package/dist/actions/actionErrorDetail.js +37 -0
  9. package/dist/actions/actionKeys.d.ts +1 -1
  10. package/dist/actions/actionKeys.js +11 -0
  11. package/dist/actions/actionResponse.d.ts +42 -0
  12. package/dist/actions/actionResponse.js +104 -0
  13. package/dist/actions/index.d.ts +3 -0
  14. package/dist/actions/index.js +3 -0
  15. package/dist/actions/serverActionHandler.d.ts +143 -0
  16. package/dist/actions/serverActionHandler.js +137 -0
  17. package/dist/evaluator/ExpressionEvaluator.js +4 -2
  18. package/dist/evaluator/fieldRules.d.ts +26 -2
  19. package/dist/evaluator/fieldRules.js +63 -8
  20. package/dist/evaluator/index.d.ts +1 -0
  21. package/dist/evaluator/index.js +1 -0
  22. package/dist/evaluator/listConditional.d.ts +13 -1
  23. package/dist/evaluator/listConditional.js +0 -0
  24. package/dist/evaluator/optionRules.js +5 -1
  25. package/dist/evaluator/predicateInput.d.ts +84 -0
  26. package/dist/evaluator/predicateInput.js +72 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +12 -0
  29. package/dist/protocols/index.d.ts +0 -1
  30. package/dist/protocols/index.js +8 -1
  31. package/dist/utils/dataset-format.d.ts +6 -1
  32. package/dist/utils/dataset-pivot.d.ts +57 -0
  33. package/dist/utils/dataset-pivot.js +57 -0
  34. package/dist/utils/filter-converter.d.ts +39 -11
  35. package/dist/utils/filter-converter.js +87 -7
  36. package/dist/utils/predicate-fields.d.ts +96 -0
  37. package/dist/utils/predicate-fields.js +163 -0
  38. package/dist/utils/predicate-record.d.ts +32 -0
  39. package/dist/utils/predicate-record.js +120 -0
  40. package/dist/utils/record-title.js +0 -0
  41. package/package.json +4 -4
  42. package/dist/protocols/NotificationProtocol.d.ts +0 -71
  43. package/dist/protocols/NotificationProtocol.js +0 -89
@@ -0,0 +1,84 @@
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 normalized shape {@link ExpressionEvaluator.evaluateCondition} consumes:
10
+ * a boolean (short-circuits), a `${…}` template string (legacy JS path), a
11
+ * `{ dialect: 'cel', source }` envelope (canonical `@objectstack/formula`
12
+ * path), or `undefined` for "no predicate declared".
13
+ *
14
+ * ## Why not `PredicateInput` (objectstack#4115 / objectui#3074)
15
+ *
16
+ * `@objectstack/spec` owns `PredicateInput` — and it is a genuinely DIFFERENT
17
+ * concept, so wearing that name would be the exact drift this repo's
18
+ * `check:spec-symbols` gate exists to stop. The spec's is
19
+ * `z.input<typeof PredicateInputSchema>`: what an author WRITES, before
20
+ * normalization — a bare string, or an envelope over the full dialect set
21
+ * (`cel` | `cron` | `template`) with optional `source` / `ast` / `meta`.
22
+ *
23
+ * This one is the OUTPUT of normalization: what the evaluator ACCEPTS. It
24
+ * admits `boolean` and the `${…}` template spelling (neither of which the spec
25
+ * models as a predicate), narrows `dialect` to `cel` alone (every other dialect
26
+ * has already been flattened onto the legacy path by `toPredicateInput`),
27
+ * requires a non-empty `source`, and carries no `ast` / `meta`. The two unions
28
+ * are not mutually assignable in either direction.
29
+ *
30
+ * The rename is pinned from both sides by the tripwire in
31
+ * `packages/core/src/utils/__tests__/spec-symbol-batch4.test.ts`: the spec must
32
+ * still own `PredicateInput` (else this rename has lost its reason), and must
33
+ * still NOT own `EvaluatorPredicateInput` (the objectui#3074 mistake of
34
+ * renaming onto another name the spec already holds).
35
+ */
36
+ export type EvaluatorPredicateInput = string | boolean | {
37
+ dialect: 'cel';
38
+ source: string;
39
+ } | undefined;
40
+ /**
41
+ * Normalize a schema-supplied predicate (`visible` / `enabled` / `disabled` /
42
+ * `hidden`) into the form {@link ExpressionEvaluator.evaluateCondition}
43
+ * expects.
44
+ *
45
+ * Accepts:
46
+ * - `boolean` → returned as-is (predicate evaluation short-circuits).
47
+ * - `string` → wrapped as `${string}` (legacy DX shorthand).
48
+ * - `Expression` envelope `{ dialect, source }` (the normalized form
49
+ * `@objectstack/spec`'s `ExpressionInputSchema` produces for every
50
+ * authored predicate, including bare strings) → a `cel` dialect keeps
51
+ * its envelope; every other dialect is unwrapped and wrapped as
52
+ * `${source}`.
53
+ * - `null` / `undefined` / empty / anything else → `undefined`
54
+ * (default visible/enabled).
55
+ *
56
+ * ## Why the `cel` envelope must survive normalization (#2661 / #3314)
57
+ *
58
+ * `evaluateCondition` routes to the canonical `@objectstack/formula` engine —
59
+ * the one the SERVER enforces with — only while the argument is still a
60
+ * `{ dialect: 'cel' }` object. Collapse it to `${source}` first and the
61
+ * predicate silently falls to the legacy JS evaluator, whose semantics
62
+ * differ: CEL has no `<` overload for `null`, so `null < null` faults and
63
+ * fail-closed hides, while JS quietly yields `false`; CEL-only builtins
64
+ * (`today()`, objectstack#3205) do not exist on the JS path at all. Two
65
+ * normalizers that disagree on this one branch therefore make the SAME
66
+ * `visible:` predicate reach different verdicts depending on whether the
67
+ * action was surfaced by `ActionEngine.getActionsForLocation` or rendered
68
+ * standalone (#3314).
69
+ *
70
+ * This is THE implementation, not one of two — `@object-ui/core` is the common
71
+ * dependency of every consumer, so engine-side code and renderer-side code
72
+ * share one normalization instead of hand-rolling envelope unwrapping per call
73
+ * site. `@object-ui/react`'s `toPredicateInput` is a re-export of this function
74
+ * (since #3367; it used to be an independent twin held in step by a 14-shape
75
+ * normalization parity table, which is exactly the arrangement the paragraph
76
+ * above describes the failure mode of). What pins that now is the identity
77
+ * assertion in
78
+ * `packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx` — the
79
+ * react export must BE this function object — alongside the engine-path vs
80
+ * renderer-path verdict parity suite in the same file, which is a separate
81
+ * claim and still earns its keep: sharing a normalizer does not by itself
82
+ * prove the two call paths reach the same verdict.
83
+ */
84
+ export declare function toPredicateInput(value: unknown): EvaluatorPredicateInput;
@@ -0,0 +1,72 @@
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
+ * Normalize a schema-supplied predicate (`visible` / `enabled` / `disabled` /
10
+ * `hidden`) into the form {@link ExpressionEvaluator.evaluateCondition}
11
+ * expects.
12
+ *
13
+ * Accepts:
14
+ * - `boolean` → returned as-is (predicate evaluation short-circuits).
15
+ * - `string` → wrapped as `${string}` (legacy DX shorthand).
16
+ * - `Expression` envelope `{ dialect, source }` (the normalized form
17
+ * `@objectstack/spec`'s `ExpressionInputSchema` produces for every
18
+ * authored predicate, including bare strings) → a `cel` dialect keeps
19
+ * its envelope; every other dialect is unwrapped and wrapped as
20
+ * `${source}`.
21
+ * - `null` / `undefined` / empty / anything else → `undefined`
22
+ * (default visible/enabled).
23
+ *
24
+ * ## Why the `cel` envelope must survive normalization (#2661 / #3314)
25
+ *
26
+ * `evaluateCondition` routes to the canonical `@objectstack/formula` engine —
27
+ * the one the SERVER enforces with — only while the argument is still a
28
+ * `{ dialect: 'cel' }` object. Collapse it to `${source}` first and the
29
+ * predicate silently falls to the legacy JS evaluator, whose semantics
30
+ * differ: CEL has no `<` overload for `null`, so `null < null` faults and
31
+ * fail-closed hides, while JS quietly yields `false`; CEL-only builtins
32
+ * (`today()`, objectstack#3205) do not exist on the JS path at all. Two
33
+ * normalizers that disagree on this one branch therefore make the SAME
34
+ * `visible:` predicate reach different verdicts depending on whether the
35
+ * action was surfaced by `ActionEngine.getActionsForLocation` or rendered
36
+ * standalone (#3314).
37
+ *
38
+ * This is THE implementation, not one of two — `@object-ui/core` is the common
39
+ * dependency of every consumer, so engine-side code and renderer-side code
40
+ * share one normalization instead of hand-rolling envelope unwrapping per call
41
+ * site. `@object-ui/react`'s `toPredicateInput` is a re-export of this function
42
+ * (since #3367; it used to be an independent twin held in step by a 14-shape
43
+ * normalization parity table, which is exactly the arrangement the paragraph
44
+ * above describes the failure mode of). What pins that now is the identity
45
+ * assertion in
46
+ * `packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx` — the
47
+ * react export must BE this function object — alongside the engine-path vs
48
+ * renderer-path verdict parity suite in the same file, which is a separate
49
+ * claim and still earns its keep: sharing a normalizer does not by itself
50
+ * prove the two call paths reach the same verdict.
51
+ */
52
+ export function toPredicateInput(value) {
53
+ if (value === null || value === undefined || value === '')
54
+ return undefined;
55
+ if (typeof value === 'boolean')
56
+ return value;
57
+ if (typeof value === 'string')
58
+ return `\${${value}}`;
59
+ if (typeof value === 'object' && typeof value.source === 'string') {
60
+ const src = value.source;
61
+ if (!src)
62
+ return undefined;
63
+ // Preserve a CEL-dialect envelope so `evaluateCondition` routes it to the
64
+ // canonical `@objectstack/formula` engine (identical verdict to the
65
+ // server) instead of collapsing it onto the legacy JS path.
66
+ if (value.dialect === 'cel')
67
+ return { dialect: 'cel', source: src };
68
+ // Every other dialect (template / unset) keeps the legacy `${…}` behavior.
69
+ return `\${${src}}`;
70
+ }
71
+ return undefined;
72
+ }
package/dist/index.d.ts CHANGED
@@ -48,7 +48,10 @@ export * from './utils/merge-filters.js';
48
48
  export * from './utils/compare-to.js';
49
49
  export * from './utils/chart-series.js';
50
50
  export * from './utils/dataset-format.js';
51
+ export * from './utils/dataset-pivot.js';
51
52
  export * from './utils/record-title.js';
52
53
  export * from './utils/export-filename.js';
53
54
  export * from './utils/reference-keys.js';
55
+ export * from './utils/predicate-record.js';
56
+ export * from './utils/predicate-fields.js';
54
57
  export * from './utils/normalize-list-view.js';
package/dist/index.js CHANGED
@@ -50,7 +50,19 @@ export * from './utils/merge-filters.js';
50
50
  export * from './utils/compare-to.js';
51
51
  export * from './utils/chart-series.js';
52
52
  export * from './utils/dataset-format.js';
53
+ // Pivot lookup-key encoders, shared by every cross-tab renderer so the
54
+ // dashboard widget and the report renderer key their buckets identically
55
+ // (objectstack#5473 / objectstack#5665).
56
+ export * from './utils/dataset-pivot.js';
53
57
  export * from './utils/record-title.js';
54
58
  export * from './utils/export-filename.js';
55
59
  export * from './utils/reference-keys.js';
60
+ // Binds a fetched record into an expression scope the way the SERVER binds it
61
+ // (a relation is its foreign key, never the expanded record) — see
62
+ // `toPredicateRecord` for why an unnormalized one gives the same predicate
63
+ // different verdicts on different surfaces.
64
+ export * from './utils/predicate-record.js';
65
+ // The other half of a view's field appetite: the fields its PREDICATES read,
66
+ // which the column-derived `$select` never asked the server for.
67
+ export * from './utils/predicate-fields.js';
56
68
  export * from './utils/normalize-list-view.js';
@@ -7,6 +7,5 @@
7
7
  */
8
8
  export * from './DndProtocol.js';
9
9
  export * from './KeyboardProtocol.js';
10
- export * from './NotificationProtocol.js';
11
10
  export * from './ResponsiveProtocol.js';
12
11
  export * from './SharingProtocol.js';
@@ -7,6 +7,13 @@
7
7
  */
8
8
  export * from './DndProtocol.js';
9
9
  export * from './KeyboardProtocol.js';
10
- export * from './NotificationProtocol.js';
10
+ // `NotificationProtocol` removed in the 17.0.0-rc.2 uptake: it bridged
11
+ // `@objectstack/spec/ui`'s `Notification` / `NotificationConfig`, which
12
+ // objectstack#4610 deleted with no successor. The bridge had zero consumers in
13
+ // this repo — the live notification config resolution is
14
+ // `resolveNotificationConfig` in `@object-ui/react`'s `NotificationContext`,
15
+ // which declares its own `NotificationSystemConfig` and is what every surface
16
+ // reads. Re-typing the bridge would have meant re-declaring a vocabulary the
17
+ // spec had just retired (AGENTS.md #0.1).
11
18
  export * from './ResponsiveProtocol.js';
12
19
  export * from './SharingProtocol.js';
@@ -15,6 +15,7 @@
15
15
  * Pure (no React / i18n) so it can live in `@object-ui/core` and be imported
16
16
  * by both `@object-ui/plugin-dashboard` and `@object-ui/plugin-report`.
17
17
  */
18
+ import type { PercentScale } from '@objectstack/spec/data';
18
19
  /**
19
20
  * Column metadata the analytics server returns alongside the rows: a display
20
21
  * `label` for both dimensions and measures, plus a measure's numeral `format`
@@ -38,8 +39,12 @@ export interface DatasetResultField {
38
39
  * field's scale) and carried on the result column, so display never has to
39
40
  * infer it from the value's magnitude — the inference that printed a ratio of
40
41
  * exactly 1 as "1.0%" (#3136).
42
+ *
43
+ * Spec-owned since 17.0.0-rc.2 (`@objectstack/spec/data` exports the identical
44
+ * union); re-exported here so existing `@object-ui/core` consumers keep their
45
+ * import path.
41
46
  */
42
- export type PercentScale = 'fraction' | 'whole';
47
+ export type { PercentScale } from '@objectstack/spec/data';
43
48
  /**
44
49
  * Scale a stored `percent`-field value to its DISPLAY magnitude.
45
50
  *
@@ -0,0 +1,57 @@
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
+ * dataset-pivot — the lookup-key encoders every cross-tab over a semantic-layer
10
+ * `queryDataset` result shares (ADR-0021).
11
+ *
12
+ * A pivot keys three lookups off dimension-value tuples: the DOWN bucket, the
13
+ * ACROSS bucket, and the cell where the two meet. Every one of them used to be
14
+ * spelled by concatenating values with a character the data was ASSUMED never
15
+ * to contain — an empty string, a plain space, U+0001 — and each assumption
16
+ * failed on ordinary data: `"x"` + `"yz"` and `"xy"` + `"z"` are one id under an
17
+ * empty join, and `"New"` × `"York Q1"` and `"New York"` × `"Q1"` are one cell
18
+ * key under a space join. The later row silently overwrote the earlier one, so
19
+ * the cell showed a different row's measure, the overwritten row's value was
20
+ * unreachable, and drill-through followed the same wrong index into the wrong
21
+ * records — all without an error (objectstack#5473, objectstack#5665).
22
+ *
23
+ * `JSON.stringify` carries the boundary in its own quoting rather than in a
24
+ * character the values must avoid, so these are unambiguous for ANY value a
25
+ * dimension can hold. The ids are opaque lookup keys, never displayed — visible
26
+ * text comes from the separately-formatted labels.
27
+ *
28
+ * Both encoders live here, in `@object-ui/core`, because the same pivot is
29
+ * rendered by two packages (`plugin-dashboard`'s `DatasetWidget` and
30
+ * `plugin-report`'s `DatasetReportRenderer`). Each having written its own is
31
+ * exactly why both carried the defect and why fixing one left the other broken.
32
+ * The same rule applies WITHIN a renderer: the cell index and the subtotal
33
+ * lookups key the same buckets, so they must all call these — a second
34
+ * hand-rolled encoding of the same id agrees with the first only for the
35
+ * dimension counts someone happened to test, which is what kept the original
36
+ * bug invisible.
37
+ *
38
+ * Pure (no React / i18n), like its `dataset-format` neighbour.
39
+ *
40
+ * Known residual, tracked separately in objectstack#5666: callers encode a
41
+ * null/undefined dimension value as a placeholder string, so it still collides
42
+ * with a value that literally equals that placeholder. That is a property of
43
+ * the placeholder, not of the encoding below.
44
+ */
45
+ /**
46
+ * Encode a pivot BUCKET id from its dimension values.
47
+ *
48
+ * Axis-neutral on purpose: a DOWN bucket and an ACROSS bucket are the same kind
49
+ * of thing (a dimension-value tuple), and a cross-tab with multiple across
50
+ * dimensions collides on that axis just as readily as on the down axis.
51
+ */
52
+ export declare const pivotBucketId: (dimensionValues: string[]) => string;
53
+ /**
54
+ * Encode the cell key for a (down bucket, across bucket) pair — the key of the
55
+ * map a renderer reads to place a measure and to resolve a drill-through.
56
+ */
57
+ export declare const pivotCellKey: (rowId: string, colId: string) => string;
@@ -0,0 +1,57 @@
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
+ * dataset-pivot — the lookup-key encoders every cross-tab over a semantic-layer
10
+ * `queryDataset` result shares (ADR-0021).
11
+ *
12
+ * A pivot keys three lookups off dimension-value tuples: the DOWN bucket, the
13
+ * ACROSS bucket, and the cell where the two meet. Every one of them used to be
14
+ * spelled by concatenating values with a character the data was ASSUMED never
15
+ * to contain — an empty string, a plain space, U+0001 — and each assumption
16
+ * failed on ordinary data: `"x"` + `"yz"` and `"xy"` + `"z"` are one id under an
17
+ * empty join, and `"New"` × `"York Q1"` and `"New York"` × `"Q1"` are one cell
18
+ * key under a space join. The later row silently overwrote the earlier one, so
19
+ * the cell showed a different row's measure, the overwritten row's value was
20
+ * unreachable, and drill-through followed the same wrong index into the wrong
21
+ * records — all without an error (objectstack#5473, objectstack#5665).
22
+ *
23
+ * `JSON.stringify` carries the boundary in its own quoting rather than in a
24
+ * character the values must avoid, so these are unambiguous for ANY value a
25
+ * dimension can hold. The ids are opaque lookup keys, never displayed — visible
26
+ * text comes from the separately-formatted labels.
27
+ *
28
+ * Both encoders live here, in `@object-ui/core`, because the same pivot is
29
+ * rendered by two packages (`plugin-dashboard`'s `DatasetWidget` and
30
+ * `plugin-report`'s `DatasetReportRenderer`). Each having written its own is
31
+ * exactly why both carried the defect and why fixing one left the other broken.
32
+ * The same rule applies WITHIN a renderer: the cell index and the subtotal
33
+ * lookups key the same buckets, so they must all call these — a second
34
+ * hand-rolled encoding of the same id agrees with the first only for the
35
+ * dimension counts someone happened to test, which is what kept the original
36
+ * bug invisible.
37
+ *
38
+ * Pure (no React / i18n), like its `dataset-format` neighbour.
39
+ *
40
+ * Known residual, tracked separately in objectstack#5666: callers encode a
41
+ * null/undefined dimension value as a placeholder string, so it still collides
42
+ * with a value that literally equals that placeholder. That is a property of
43
+ * the placeholder, not of the encoding below.
44
+ */
45
+ /**
46
+ * Encode a pivot BUCKET id from its dimension values.
47
+ *
48
+ * Axis-neutral on purpose: a DOWN bucket and an ACROSS bucket are the same kind
49
+ * of thing (a dimension-value tuple), and a cross-tab with multiple across
50
+ * dimensions collides on that axis just as readily as on the down axis.
51
+ */
52
+ export const pivotBucketId = (dimensionValues) => JSON.stringify(dimensionValues);
53
+ /**
54
+ * Encode the cell key for a (down bucket, across bucket) pair — the key of the
55
+ * map a renderer reads to place a measure and to resolve a drill-through.
56
+ */
57
+ export const pivotCellKey = (rowId, colId) => JSON.stringify([rowId, colId]);
@@ -5,12 +5,6 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- /**
9
- * Filter Converter Utilities
10
- *
11
- * Shared utilities for converting MongoDB-like filter operators
12
- * to ObjectStack FilterNode AST format.
13
- */
14
8
  /**
15
9
  * FilterNode AST type definition
16
10
  * Represents a filter condition or a logical combination of conditions
@@ -83,6 +77,36 @@ export declare function convertFiltersToAST(filter: Record<string, any>): Filter
83
77
  * `table.defaultFilters` (declared `Record<string, any>`) was DROPPED and the
84
78
  * view returned every record. Silently: no error, just a wider answer.
85
79
  *
80
+ * The FIRST never worked at all (objectui#3431). The array branch returned
81
+ * every array VERBATIM, so a saved view's `ViewFilterRule[]` travelled to
82
+ * `$filter` as bare rule objects — which the server refuses: `isFilterAST` is
83
+ * false for an array of objects, and the wire face answers `400
84
+ * INVALID_FILTER`. Verified against a real backend on the showcase's SHIPPED
85
+ * `showcase_task.in_progress` view (`filter: [{field:'status',
86
+ * operator:'equals', value:'in_progress'}]`): 400 as sent, 200 with its 2 rows
87
+ * once lowered to `[['status','equals','in_progress']]`. Every saved view
88
+ * carrying a filter was a failed list. `mergeFilterNodes` below has warned
89
+ * about exactly this hazard since it was written — the warning was accurate,
90
+ * and the sink it warns for never implemented the lowering it describes.
91
+ *
92
+ * **Why the fold lives HERE and not at the producer.** `ListViewSchema.filter`
93
+ * / `ViewTab.filter` are spec-declared `z.array(ViewFilterRuleSchema)`, and a
94
+ * view hands a renderer a `ListViewSchema` — so folding one hop earlier (in
95
+ * `app-shell`'s ObjectView, say) would write ObjectQL AST triples INTO a
96
+ * spec-declared rule-array slot: an off-spec value in a spec field, AGENTS.md
97
+ * #0.1 inverted. This function is the last hop before the wire, where the value
98
+ * legitimately leaves the spec's view vocabulary and becomes an ObjectQL AST.
99
+ * It is also the single sink both producers already share — `plugin-list`'s
100
+ * `buildEffectiveFilter` (which feeds the grid AND the export) and
101
+ * `plugin-view`'s ObjectView (calendar / kanban / gallery / timeline). One
102
+ * lowering, one place; the same reason the MongoDB-style shape is lowered here
103
+ * rather than at each of its callers.
104
+ *
105
+ * Mixed arrays fold ELEMENT-WISE, because that is what reaches this function in
106
+ * practice: `ObjectView` concatenates a saved view's rules with the
107
+ * `?filter[<field>]=<value>` URL triples into one array. Triples pass through
108
+ * untouched.
109
+ *
86
110
  * Returns `undefined` for an absent or empty source, so callers can skip
87
111
  * `$filter` rather than sending an empty array.
88
112
  */
@@ -92,11 +116,15 @@ export declare function toFilterNode(source: unknown): FilterNode | Record<strin
92
116
  *
93
117
  * Wrapping rather than spreading, on purpose. `['and', ...rules]` looks
94
118
  * equivalent and is not: spreading a `ViewFilterRule[]` puts bare rule OBJECTS
95
- * where the AST expects nodes, and the server neither understands nor rejects
96
- * that cleanly — `isFilterAST` says no (a 400 since objectstack#4121), while
97
- * `parseFilterAST` reads the rule as a Mongo condition and filters on columns
98
- * literally named `field` / `operator` / `value`. Spreading is only correct
99
- * when the source happens to be an array of nodes, which is why it survived.
119
+ * where the AST expects nodes, and the server does not accept that —
120
+ * `isFilterAST` says no and the wire face answers `400 INVALID_FILTER`
121
+ * (objectstack#4121). Spreading is only correct when the source happens to be
122
+ * an array of nodes, which is why it survived.
123
+ *
124
+ * Since objectui#3431 the rule objects never reach that position anyway:
125
+ * `toFilterNode` lowers a `ViewFilterRule[]` to AST nodes on the way in. The
126
+ * paragraph above is kept because it is why each source stays its own child —
127
+ * and because it correctly diagnosed the bug the sink itself was carrying.
100
128
  *
101
129
  * Sources that normalize to nothing are skipped; one surviving source is
102
130
  * returned as-is rather than wrapped in a pointless `and`.
@@ -5,6 +5,13 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
+ /**
9
+ * Filter Converter Utilities
10
+ *
11
+ * Shared utilities for converting MongoDB-like filter operators
12
+ * to ObjectStack FilterNode AST format.
13
+ */
14
+ import { normalizeFilterOperator } from '@objectstack/spec/ui';
8
15
  /**
9
16
  * Map MongoDB-like operators to ObjectStack filter operators.
10
17
  *
@@ -152,6 +159,37 @@ export function convertFiltersToAST(filter) {
152
159
  // Multiple conditions: combine with 'and'
153
160
  return ['and', ...conditions];
154
161
  }
162
+ function isViewFilterRule(value) {
163
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
164
+ return false;
165
+ const field = value.field;
166
+ return typeof field === 'string' && field !== '';
167
+ }
168
+ /**
169
+ * Lower ONE spec `ViewFilterRule` to an ObjectQL AST comparison node.
170
+ *
171
+ * The operator goes through the spec's OWN {@link normalizeFilterOperator} —
172
+ * the exact exit the WRITE side uses (`app-shell/views/viewFilterFold.ts`), so
173
+ * the two directions cannot drift into two dialects. No second canonical map is
174
+ * introduced, and none is needed: all 19 `VIEW_FILTER_OPERATORS` are already
175
+ * members of the wire's `VALID_AST_OPERATORS`, so the lowering is purely
176
+ * structural. An operator the spec does not know is passed through VERBATIM, so
177
+ * `isFilterAST` still refuses it and the server still answers `400
178
+ * INVALID_FILTER` — a misspelling must not be coerced into a valid one.
179
+ *
180
+ * `value` is emitted only when the rule carries one. A rule that omits it
181
+ * (`is_empty` / `is_null`, whose direction comes from the operator NAME) would
182
+ * otherwise gain an invented `null` the author never wrote — `JSON.stringify`
183
+ * turns a hole in an array into `null`, and `['x', 'equals', null]` is a real
184
+ * `{x: null}` predicate, i.e. a silently-wrong filter. Same rule the write side
185
+ * applies (`if (c.value !== undefined)`).
186
+ */
187
+ function viewFilterRuleToNode(rule) {
188
+ const operator = normalizeFilterOperator(rule.operator);
189
+ return (rule.value === undefined
190
+ ? [rule.field, operator]
191
+ : [rule.field, operator, rule.value]);
192
+ }
155
193
  /**
156
194
  * Normalize ONE filter source into a single filter node.
157
195
  *
@@ -167,14 +205,52 @@ export function convertFiltersToAST(filter) {
167
205
  * `table.defaultFilters` (declared `Record<string, any>`) was DROPPED and the
168
206
  * view returned every record. Silently: no error, just a wider answer.
169
207
  *
208
+ * The FIRST never worked at all (objectui#3431). The array branch returned
209
+ * every array VERBATIM, so a saved view's `ViewFilterRule[]` travelled to
210
+ * `$filter` as bare rule objects — which the server refuses: `isFilterAST` is
211
+ * false for an array of objects, and the wire face answers `400
212
+ * INVALID_FILTER`. Verified against a real backend on the showcase's SHIPPED
213
+ * `showcase_task.in_progress` view (`filter: [{field:'status',
214
+ * operator:'equals', value:'in_progress'}]`): 400 as sent, 200 with its 2 rows
215
+ * once lowered to `[['status','equals','in_progress']]`. Every saved view
216
+ * carrying a filter was a failed list. `mergeFilterNodes` below has warned
217
+ * about exactly this hazard since it was written — the warning was accurate,
218
+ * and the sink it warns for never implemented the lowering it describes.
219
+ *
220
+ * **Why the fold lives HERE and not at the producer.** `ListViewSchema.filter`
221
+ * / `ViewTab.filter` are spec-declared `z.array(ViewFilterRuleSchema)`, and a
222
+ * view hands a renderer a `ListViewSchema` — so folding one hop earlier (in
223
+ * `app-shell`'s ObjectView, say) would write ObjectQL AST triples INTO a
224
+ * spec-declared rule-array slot: an off-spec value in a spec field, AGENTS.md
225
+ * #0.1 inverted. This function is the last hop before the wire, where the value
226
+ * legitimately leaves the spec's view vocabulary and becomes an ObjectQL AST.
227
+ * It is also the single sink both producers already share — `plugin-list`'s
228
+ * `buildEffectiveFilter` (which feeds the grid AND the export) and
229
+ * `plugin-view`'s ObjectView (calendar / kanban / gallery / timeline). One
230
+ * lowering, one place; the same reason the MongoDB-style shape is lowered here
231
+ * rather than at each of its callers.
232
+ *
233
+ * Mixed arrays fold ELEMENT-WISE, because that is what reaches this function in
234
+ * practice: `ObjectView` concatenates a saved view's rules with the
235
+ * `?filter[<field>]=<value>` URL triples into one array. Triples pass through
236
+ * untouched.
237
+ *
170
238
  * Returns `undefined` for an absent or empty source, so callers can skip
171
239
  * `$filter` rather than sending an empty array.
172
240
  */
173
241
  export function toFilterNode(source) {
174
242
  if (source === null || source === undefined)
175
243
  return undefined;
176
- if (Array.isArray(source))
177
- return source.length > 0 ? source : undefined;
244
+ if (Array.isArray(source)) {
245
+ if (source.length === 0)
246
+ return undefined;
247
+ // Spec `ViewFilterRule[]` (possibly mixed with AST nodes) → AST nodes. Left
248
+ // untouched when the array holds no rule objects, which is the common case:
249
+ // user-filter conditions and URL triples are already nodes.
250
+ return (source.some(isViewFilterRule)
251
+ ? source.map((el) => (isViewFilterRule(el) ? viewFilterRuleToNode(el) : el))
252
+ : source);
253
+ }
178
254
  if (typeof source !== 'object')
179
255
  return undefined;
180
256
  const obj = source;
@@ -188,11 +264,15 @@ export function toFilterNode(source) {
188
264
  *
189
265
  * Wrapping rather than spreading, on purpose. `['and', ...rules]` looks
190
266
  * equivalent and is not: spreading a `ViewFilterRule[]` puts bare rule OBJECTS
191
- * where the AST expects nodes, and the server neither understands nor rejects
192
- * that cleanly — `isFilterAST` says no (a 400 since objectstack#4121), while
193
- * `parseFilterAST` reads the rule as a Mongo condition and filters on columns
194
- * literally named `field` / `operator` / `value`. Spreading is only correct
195
- * when the source happens to be an array of nodes, which is why it survived.
267
+ * where the AST expects nodes, and the server does not accept that —
268
+ * `isFilterAST` says no and the wire face answers `400 INVALID_FILTER`
269
+ * (objectstack#4121). Spreading is only correct when the source happens to be
270
+ * an array of nodes, which is why it survived.
271
+ *
272
+ * Since objectui#3431 the rule objects never reach that position anyway:
273
+ * `toFilterNode` lowers a `ViewFilterRule[]` to AST nodes on the way in. The
274
+ * paragraph above is kept because it is why each source stays its own child —
275
+ * and because it correctly diagnosed the bug the sink itself was carrying.
196
276
  *
197
277
  * Sources that normalize to nothing are skipped; one surviving source is
198
278
  * returned as-is rather than wrapped in a pointless `and`.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * ObjectUI — predicate field harvesting
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
+ * Which record fields a view's PREDICATES read — the half of a view's field
10
+ * appetite that is not its columns.
11
+ *
12
+ * A list view projects what it DISPLAYS: `$select` is built from the column
13
+ * list (plus `id` and the expanded relation roots). That is correct for
14
+ * rendering and wrong for gating, because a `visible` / `disabled` /
15
+ * conditional-formatting predicate reads fields nobody put on screen — gating a
16
+ * row action on `record.owner` while showing Title / Status is the ordinary
17
+ * case, not an exotic one.
18
+ *
19
+ * The field then simply is not in the payload, and CEL does not treat an absent
20
+ * key as null: `record.owner == os.user.id` FAULTS with `No such key: owner`.
21
+ * Fault handling differs per surface and both answers are wrong — fail-CLOSED
22
+ * on the row kebab and the selection bar (the button silently disappears for
23
+ * everyone), fail-OPEN on the lenient paths (it appears for everyone). Nothing
24
+ * in either outcome points at a projection.
25
+ *
26
+ * Harvesting the names a predicate mentions and adding them to `$select` is
27
+ * what closes that. Note what it deliberately does NOT add: `$expand`. A
28
+ * predicate wants the stored foreign key, which is exactly what an unexpanded
29
+ * relation column already is — so a predicate-only relation field costs one
30
+ * more column and no extra round trip. (A relation that IS displayed still gets
31
+ * expanded for its label, and `toPredicateRecord` collapses it back for the
32
+ * predicate.)
33
+ */
34
+ /**
35
+ * The columns the framework provisions on EVERY business object
36
+ * (`applySystemFields`) but does not publish in the object's `fields` metadata.
37
+ *
38
+ * They matter here because the most ordinary ownership predicate there is —
39
+ * `record.owner_id == os.user.id` — names one of them. Checking a harvested
40
+ * name against the declared fields alone would drop it, leaving the predicate
41
+ * with the same missing key the harvest exists to prevent.
42
+ *
43
+ * Deliberately the columns OBSERVED on a live payload rather than
44
+ * `SYSTEM_MANAGED_FIELD_NAMES` (`@object-ui/types`), which is the broader
45
+ * DISPLAY-oriented set and carries legacy aliases (`_id`, `createdAt`,
46
+ * `modified`, `space`) that are not columns anywhere. An alias in `$select` is
47
+ * exactly the unknown key the guard exists to keep out.
48
+ */
49
+ export declare const PLATFORM_RECORD_COLUMNS: ReadonlySet<string>;
50
+ /**
51
+ * Whether a harvested name is safe to put in a `$select` projection: either the
52
+ * object declares it, or it is one of the platform columns every object has.
53
+ *
54
+ * The check is not pedantry — an unknown key is not ignored by every backend
55
+ * (the cloud multi-tenant runtime answers with an EMPTY result set), so without
56
+ * it one typo'd predicate could blank a whole list.
57
+ */
58
+ export declare function isProjectableField(name: string, declaredFields: Record<string, unknown> | null | undefined): boolean;
59
+ /**
60
+ * Collect the record field names referenced by a set of predicates.
61
+ *
62
+ * Accepts predicates in every authored shape (bare CEL string, the
63
+ * `{ dialect, source }` envelope, `null`/`undefined`/boolean — the last three
64
+ * contribute nothing). Returns names in first-seen order, deduplicated.
65
+ *
66
+ * The result is a set of CANDIDATES, not verified fields: a typo'd predicate
67
+ * yields a name this object does not have. Callers must intersect it with the
68
+ * object's declared fields before putting it in a query — an unknown key in
69
+ * `$select` is not ignored by every backend (the cloud runtime answers with an
70
+ * empty result set), which would turn a typo into a blank list.
71
+ */
72
+ export declare function collectPredicateFieldRefs(predicates: readonly unknown[]): string[];
73
+ /**
74
+ * Every predicate a list-ish view carries, flattened for
75
+ * {@link collectPredicateFieldRefs}.
76
+ *
77
+ * One reader so the projection cannot fall behind the surfaces: each entry here
78
+ * is a place a predicate is actually evaluated against a row — conditional
79
+ * formatting, the row kebab (custom defs AND the built-in Edit/Delete
80
+ * overrides), the selection bar, and the object's declared actions.
81
+ *
82
+ * `objectActions` is deliberately the object's WHOLE action set rather than
83
+ * only the ones this view promotes. Narrowing it would mean re-running the
84
+ * name resolution `resolveLegacyRowActions` / `resolveBulkActions` already do,
85
+ * at a point in the fetch where the view's `rowActions` / `bulkActions` lists
86
+ * have not been folded yet — and the two directions of error are not
87
+ * symmetric: an extra column costs bytes, a missing one silently breaks the
88
+ * predicate that needed it.
89
+ */
90
+ export declare function listViewPredicates(view: {
91
+ conditionalFormatting?: readonly unknown[] | null;
92
+ rowActionDefs?: readonly unknown[] | null;
93
+ bulkActionDefs?: readonly unknown[] | null;
94
+ objectActions?: readonly unknown[] | null;
95
+ userActions?: Record<string, unknown> | null;
96
+ }): unknown[];