@@ -34,6 +34,16 @@ import { isIncoherentAggregate } from '@objectstack/spec/data';
3434 * renders nothing. Errored (not warned) so this class of authoring mistake —
3535 * very often an AI emitting a removed shape — fails the build instead of
3636 * shipping a blank widget past human review.
37+ * - `dashboard-filter-field-unknown` (#3365) — a dashboard-level filter
38+ * (`dateRange` or a `globalFilters[]` entry) is wired into EVERY widget's
39+ * analytics query (#2501), but its EFFECTIVE field (after any `filterBindings`
40+ * re-target) does not exist on a bound widget's dataset object. The widget's
41+ * query then references a non-existent column and crashes at render time
42+ * (`no such column …`) — a build-decidable invariant that previously escaped
43+ * the static gate and failed only when a user opened the dashboard. A widget
44+ * opts out with `filterBindings: { <name>: false }` or re-targets to a real
45+ * field. This is the same field-existence invariant ADR-0032 enforces for
46+ * CEL formula / sharing-rule references, applied to dashboard filter fields.
3747 *
3848 * Advisory rules — severity `warning`, build stays green:
3949 *
@@ -72,6 +82,7 @@ export const TABLE_COUNT_ONLY = 'table-count-only';
7282export const MEASURE_AGGREGATE_INCOHERENT = 'measure-aggregate-incoherent' ;
7383export const WIDGET_LEGACY_ANALYTICS_SHAPE = 'widget-legacy-analytics-shape' ;
7484export const WIDGET_LEGACY_ANALYTICS_UNRENDERABLE = 'widget-legacy-analytics-unrenderable' ;
85+ export const DASHBOARD_FILTER_FIELD_UNKNOWN = 'dashboard-filter-field-unknown' ;
7586
7687/**
7788 * Pre-ADR-0021 inline-analytics keys. The single-form cutover replaced them
@@ -189,6 +200,97 @@ function list(names: Iterable<string>): string {
189200 return arr . length > 0 ? arr . join ( ', ' ) : '(none)' ;
190201}
191202
203+ // ── dashboard-filter field-existence (#3365) ─────────────────────────────────
204+
205+ /** Reserved filter name for the dashboard's built-in date range (#2501). */
206+ const DATE_RANGE_FILTER_NAME = 'dateRange' ;
207+ /**
208+ * Default field of the built-in date range when `dateRange.field` is omitted.
209+ * MUST track objectui `dashboard-filters.ts` `DATE_RANGE_DEFAULT_FIELD` — the
210+ * runtime this check shadows. `created_at` is a registry-injected system field
211+ * (below), so a bare `dateRange` never false-positives.
212+ */
213+ const DATE_RANGE_DEFAULT_FIELD = 'created_at' ;
214+
215+ /**
216+ * Registry-injected fields present on (almost) every object but NOT declared in
217+ * `object.fields`, so a dashboard filter targeting one must not be flagged as a
218+ * missing column. Superset of the objectql registry's `applySystemFields`
219+ * (audit columns, ownership, tenant, soft-delete) and spec's `SystemFieldName`.
220+ * Deliberately generous: the cost of over-inclusion is at worst a missed error
221+ * on a `systemFields: false` object (rare); the cost of under-inclusion is a
222+ * false build failure on the ubiquitous `dateRange` → `created_at` default. The
223+ * near-zero-false-positive bias mirrors ADR-0032's field-ref validator.
224+ */
225+ const SYSTEM_FIELDS = new Set < string > ( [
226+ 'id' ,
227+ 'created_at' , 'created_by' , 'updated_at' , 'updated_by' ,
228+ 'owner_id' , 'organization_id' , 'tenant_id' , 'user_id' ,
229+ 'deleted_at' ,
230+ ] ) ;
231+
232+ interface DashFilterDef {
233+ /** Stable filter name — the key widgets bind against in `filterBindings`. */
234+ name : string ;
235+ /** Default target field when a widget declares no explicit binding. */
236+ field : string ;
237+ /** Legacy widget-id allow-list; gates the DEFAULT binding only. */
238+ targetWidgets ?: string [ ] ;
239+ }
240+
241+ /**
242+ * Normalize a dashboard's declared filters into `{ name, field, targetWidgets }`
243+ * defs — the built-in `dateRange` (reserved name) first, then every
244+ * `globalFilters[]` entry named by its `name` (defaulting to `field`). Later
245+ * duplicates win. Mirrors objectui `resolveDashboardFilterDefs`.
246+ */
247+ function dashboardFilterDefs ( dash : AnyRec ) : DashFilterDef [ ] {
248+ const byName = new Map < string , DashFilterDef > ( ) ;
249+
250+ const dateRange = dash . dateRange ;
251+ if ( dateRange && typeof dateRange === 'object' ) {
252+ const declared = ( dateRange as AnyRec ) . field ;
253+ const field = typeof declared === 'string' && declared ? declared : DATE_RANGE_DEFAULT_FIELD ;
254+ byName . set ( DATE_RANGE_FILTER_NAME , { name : DATE_RANGE_FILTER_NAME , field } ) ;
255+ }
256+
257+ for ( const f of asArray ( dash . globalFilters ) ) {
258+ if ( typeof f . field !== 'string' || ! f . field ) continue ;
259+ const name = typeof f . name === 'string' && f . name ? f . name : f . field ;
260+ const targetWidgets = Array . isArray ( f . targetWidgets )
261+ ? f . targetWidgets . filter ( ( w ) : w is string => typeof w === 'string' )
262+ : undefined ;
263+ byName . set ( name , { name, field : f . field , targetWidgets } ) ;
264+ }
265+
266+ return [ ...byName . values ( ) ] ;
267+ }
268+
269+ /**
270+ * Resolve which field of `widget` a filter binds to, or `undefined` when the
271+ * widget is not bound (opted out / not targeted). Precedence mirrors objectui
272+ * `resolveBoundField`: explicit `filterBindings` entry (string re-targets,
273+ * `false` opts out — both win) → legacy `targetWidgets` allow-list → the
274+ * filter's own default `field`. `explicit` distinguishes an author-chosen field
275+ * (a typo they must fix) from the inherited default (which they may opt out of).
276+ */
277+ function effectiveFilterField (
278+ widget : AnyRec ,
279+ def : DashFilterDef ,
280+ ) : { field : string ; explicit : boolean } | undefined {
281+ const bindings = widget . filterBindings ;
282+ const binding = bindings && typeof bindings === 'object'
283+ ? ( bindings as AnyRec ) [ def . name ]
284+ : undefined ;
285+ if ( binding === false ) return undefined ;
286+ if ( typeof binding === 'string' && binding ) return { field : binding , explicit : true } ;
287+ if ( def . targetWidgets && def . targetWidgets . length > 0 ) {
288+ const id = typeof widget . id === 'string' ? widget . id : undefined ;
289+ if ( ! id || ! def . targetWidgets . includes ( id ) ) return undefined ;
290+ }
291+ return { field : def . field , explicit : false } ;
292+ }
293+
192294/**
193295 * Validate every dashboard widget's dataset binding. Returns the list of
194296 * findings (empty = clean). Caller decides how to surface them: `error`
@@ -253,6 +355,9 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] {
253355 const dash = dashboards [ i ] ;
254356 const dashName = typeof dash . name === 'string' ? dash . name : `(dashboard ${ i } )` ;
255357 const widgets = Array . isArray ( dash . widgets ) ? ( dash . widgets as AnyRec [ ] ) : [ ] ;
358+ // Dashboard-level filters (`dateRange` + `globalFilters`) are broadcast into
359+ // every widget's query (#2501) — resolved once here, checked per widget below.
360+ const dashFilterDefs = dashboardFilterDefs ( dash ) ;
256361
257362 for ( let j = 0 ; j < widgets . length ; j ++ ) {
258363 const w = widgets [ j ] ;
@@ -331,6 +436,49 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] {
331436 // Without a resolved dataset there is nothing to check names against.
332437 if ( ! dataset ) continue ;
333438
439+ // ── (a1) dashboard filter fields exist on the widget's object (#3365) ──
440+ // Each dashboard-level filter is ANDed into this widget's analytics query
441+ // (#2501); a filter whose EFFECTIVE field (after `filterBindings`) is not a
442+ // column on the bound dataset object emits SQL like `WHERE close_date …`
443+ // against a table without that column and the widget crashes at query time.
444+ // Errored (not warned): a broken query, not advice. The opt-out is the
445+ // author's own `filterBindings: { <name>: false }`, so no suppression needed.
446+ if ( dashFilterDefs . length > 0 ) {
447+ const datasetObject = typeof dataset . object === 'string' ? dataset . object : undefined ;
448+ // Only judge when the bound object's fields are known in THIS stack; an
449+ // object from another installed package is unknowable here — skip rather
450+ // than false-positive (mirrors the measure-aggregate check above).
451+ const objectFields = datasetObject ? objectFieldTypes . get ( datasetObject ) : undefined ;
452+ if ( objectFields ) {
453+ for ( const def of dashFilterDefs ) {
454+ const eff = effectiveFilterField ( w , def ) ;
455+ if ( ! eff ) continue ; // opted out / not targeted → filter never applies
456+ const field = eff . field ;
457+ // A relationship path (`account.region`) is resolved by the query
458+ // engine, not a base column, so it can't be checked here — skip it.
459+ if ( field . includes ( '.' ) ) continue ;
460+ if ( objectFields . has ( field ) || SYSTEM_FIELDS . has ( field ) ) continue ;
461+ push ( {
462+ severity : 'error' ,
463+ rule : DASHBOARD_FILTER_FIELD_UNKNOWN ,
464+ message : eff . explicit
465+ ? `binds dashboard filter \`${ def . name } \` to field \`${ field } \` ` +
466+ `(via filterBindings), but object \`${ datasetObject } \` (dataset "${ dsName } ") ` +
467+ `has no field \`${ field } \`.`
468+ : `inherits dashboard filter \`${ def . name } (${ field } )\`, but object ` +
469+ `\`${ datasetObject } \` (dataset "${ dsName } ") has no field \`${ field } \`.` ,
470+ hint : eff . explicit
471+ ? `Point filterBindings: { ${ def . name } : '<field>' } at a field that exists on ` +
472+ `\`${ datasetObject } \`, or opt out with filterBindings: { ${ def . name } : false }.` +
473+ `${ suggest ( field , objectFields . keys ( ) ) } Object fields: ${ list ( objectFields . keys ( ) ) } .`
474+ : `Set filterBindings: { ${ def . name } : false } on this widget to opt out, or ` +
475+ `re-target to an existing field with filterBindings: { ${ def . name } : '<field>' }.` +
476+ `${ suggest ( field , objectFields . keys ( ) ) } Object fields: ${ list ( objectFields . keys ( ) ) } .` ,
477+ } ) ;
478+ }
479+ }
480+ }
481+
334482 const dimensionNames = new Set < string > ( ) ;
335483 for ( const d of asArray ( dataset . dimensions ) ) {
336484 if ( typeof d . name === 'string' ) dimensionNames . add ( d . name ) ;
0 commit comments