@objectstack/service-analytics 17.0.0-rc.1 → 17.0.0-rc.2

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.
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { IAnalyticsService, Logger, DriverCapabilities, AnalyticsStrategy, AnalyticsQuery, AnalyticsResult, DatasetSelection, CubeMeta, DatasetCompareTo, StrategyContext } from '@objectstack/spec/contracts';
2
- export { AnalyticsStrategy, DatasetSelection, DriverCapabilities, StrategyContext } from '@objectstack/spec/contracts';
1
+ import { IAnalyticsService, Logger, AnalyticsDriverCapabilities, AnalyticsStrategy, AnalyticsQuery, AnalyticsResult, DatasetSelection, CubeMeta, DatasetCompareTo, StrategyContext } from '@objectstack/spec/contracts';
2
+ export { AnalyticsDriverCapabilities, AnalyticsStrategy, DatasetSelection, StrategyContext } from '@objectstack/spec/contracts';
3
3
  import { Cube, FilterCondition } from '@objectstack/spec/data';
4
4
  import { ExecutionContext } from '@objectstack/spec/kernel';
5
5
  import { Dataset } from '@objectstack/spec/ui';
@@ -268,7 +268,7 @@ interface AnalyticsServiceConfig {
268
268
  * Probe driver capabilities for the object that backs a cube.
269
269
  * The service calls this function to decide which strategy can handle a query.
270
270
  */
271
- queryCapabilities?: (cubeName: string) => DriverCapabilities;
271
+ queryCapabilities?: (cubeName: string) => AnalyticsDriverCapabilities;
272
272
  /**
273
273
  * Execute raw SQL on the driver for a given object.
274
274
  * Required for NativeSQLStrategy.
@@ -370,6 +370,26 @@ interface AnalyticsServiceConfig {
370
370
  * always wires it.
371
371
  */
372
372
  isRegisteredObject?: (name: string) => boolean;
373
+ /**
374
+ * [#4437] The FIELD NAMES `objectName` declares, or `undefined` when nothing
375
+ * authoritative can answer.
376
+ *
377
+ * Consulted by {@link AnalyticsService.ensureCube} to validate the SOURCE
378
+ * FIELD a measure resolves to BEFORE any SQL is built. `inferMeasure` maps a
379
+ * suffix convention onto a field name (`ghost_sum` → `SUM(ghost)`) and used
380
+ * to accept any spelling, so a typo'd measure reached the driver as a column
381
+ * and came back as an opaque `500 SQLITE_ERROR` — a driver error class on the
382
+ * wire for a caller-shaped mistake (ADR-0112). The DATA route already refuses
383
+ * the same mistake with a `400 INVALID_FIELD` naming the field (#4315/#4254);
384
+ * this hook is what lets the ANALYTICS route give the same answer.
385
+ *
386
+ * Same tiering as {@link isRegisteredObject}: absence means "skip the check"
387
+ * (registry-less hosts, engine doubles, external datasources whose columns
388
+ * are not mirrored locally). The production bridge in `plugin.ts` wires it
389
+ * from the same schema registry the data path's gate reads, so "which fields
390
+ * exist" has ONE answer across `/data` and `/analytics`.
391
+ */
392
+ getObjectFieldNames?: (objectName: string) => readonly string[] | undefined;
373
393
  /**
374
394
  * ADR-0021 — optional object-graph resolver used when compiling datasets:
375
395
  * `(baseObject, relationshipName) => relatedObjectName | undefined`. When
@@ -377,15 +397,23 @@ interface AnalyticsServiceConfig {
377
397
  */
378
398
  relationshipResolver?: RelationshipResolver;
379
399
  /**
380
- * ADR-0053 currency chain resolve a measure's SOURCE FIELD currency
381
- * metadata so a monetary measure that omits an explicit `currency` falls back
382
- * to the field's declared currency, then the tenant default (`ctx.currency`).
383
- * Returns the source field's `type` and (fixed-mode) `defaultCurrency`;
384
- * `undefined` for an unknown field. Non-`currency` fields never get a code.
400
+ * Resolve the metadata of a dimension's or measure's SOURCE FIELD on the
401
+ * dataset's base object the one seam through which display semantics that
402
+ * live on the field reach the result columns. `undefined` for an unknown
403
+ * field. Feeds three chains:
404
+ *
405
+ * - ADR-0053 currency: a monetary measure that omits an explicit `currency`
406
+ * falls back to the field's declared currency, then the tenant default
407
+ * (`ctx.currency`). Non-`currency` fields never get a code.
408
+ * - Percent scale (objectui#3136): a measure over a `percent` field inherits
409
+ * that field's storage scale via `percentScaleOf`, so a renderer scales by
410
+ * declared metadata instead of guessing from the value.
411
+ * - Date bucketing: a date vs datetime dimension drills by the right bound.
385
412
  */
386
- measureCurrency?: (object: string, field: string) => {
413
+ sourceFieldMeta?: (object: string, field: string) => {
387
414
  type?: string;
388
415
  defaultCurrency?: string;
416
+ max?: number;
389
417
  } | undefined;
390
418
  /** Pre-defined datasets to compile + register at construction (ADR-0021). */
391
419
  datasets?: Dataset[];
@@ -436,13 +464,15 @@ declare class AnalyticsService implements IAnalyticsService {
436
464
  private readonly datasetRegistry;
437
465
  /** Optional object-graph resolver used when compiling datasets. */
438
466
  private readonly relationshipResolver?;
439
- private readonly measureCurrency?;
467
+ private readonly sourceFieldMeta?;
440
468
  /** Optional dimension display-label resolver (select options / lookup names). */
441
469
  private readonly labelResolver?;
442
470
  /** ADR-0037 P3: pending-seed row resolver for draft data preview. */
443
471
  private readonly draftRowsResolver?;
444
472
  /** [#3867] Schema-registry probe gating cube auto-inference. */
445
473
  private readonly isRegisteredObject?;
474
+ /** [#4437] Field-name probe gating measure source-field resolution. */
475
+ private readonly getObjectFieldNames?;
446
476
  /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
447
477
  private warnedNoObjectRegistry;
448
478
  readonly cubeRegistry: CubeRegistry;
@@ -519,6 +549,35 @@ declare class AnalyticsService implements IAnalyticsService {
519
549
  * strategies pick the right aggregation function and field.
520
550
  */
521
551
  private ensureCube;
552
+ /**
553
+ * [#4437] Reject a measure whose SOURCE FIELD the backing object does not
554
+ * have, BEFORE the strategy compiles it into SQL.
555
+ *
556
+ * `inferMeasure` maps a suffix convention onto a field name and has no way to
557
+ * know whether that field exists: `ghost_sum` happily became `SUM(ghost)`, the
558
+ * driver threw `no such column`, and the caller got
559
+ * `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver
560
+ * error class on the wire, and nothing actionable, for what is a plain typo.
561
+ * The DATA route has refused the same mistake with a `400 INVALID_FIELD`
562
+ * naming the field since #4315/#4254; this is the analytics half of that
563
+ * answer, and it is deliberately the SAME envelope (`code`/`field`/`object`/
564
+ * `param`) so one mistake has one shape across both routes.
565
+ *
566
+ * What it checks, and what it deliberately does not:
567
+ *
568
+ * - Only when the cube's `sql` is a bare OBJECT NAME. An authored cube whose
569
+ * `sql` is a real SQL expression has no field list to check against.
570
+ * - Only when {@link AnalyticsServiceConfig.getObjectFieldNames} answers.
571
+ * Absent hook / unknown object → stand down (see the config field's doc).
572
+ * - Only measures whose source is a BARE COLUMN. `count(*)` has no source
573
+ * field, and a dotted reference (`account.industry`) resolves through a
574
+ * join whose target this check cannot see — both pass through untouched.
575
+ * - `id` / `created_at` / `updated_at` are admitted unconditionally, matching
576
+ * the data path's `resolveQueryFields`: they are engine-assigned rather than
577
+ * declared, and a gate stricter than the engine it guards would reject
578
+ * queries that used to work.
579
+ */
580
+ private assertMeasureFields;
522
581
  /**
523
582
  * [#3867] Gate on the cube auto-inference path: a name with no registered
524
583
  * Cube may only be inferred into one if it is a registered object.
@@ -553,7 +612,7 @@ interface AnalyticsServicePluginOptions {
553
612
  * Probe driver capabilities for a given cube.
554
613
  * When omitted, defaults to in-memory only.
555
614
  */
556
- queryCapabilities?: (cubeName: string) => DriverCapabilities;
615
+ queryCapabilities?: (cubeName: string) => AnalyticsDriverCapabilities;
557
616
  /**
558
617
  * Execute raw SQL on a driver. Enables NativeSQLStrategy.
559
618
  */
@@ -633,6 +692,14 @@ declare class AnalyticsServicePlugin implements Plugin {
633
692
  version: string;
634
693
  type: "standard";
635
694
  dependencies: string[];
695
+ /**
696
+ * init() probes the `data` engine ObjectQLPlugin provides for the
697
+ * auto-bridge — order-if-present so the probe verdict is deterministic
698
+ * (ADR-0116, #4471). Soft, not hard: without an engine the plugin
699
+ * degrades on purpose (per-query lazy resolution / explicit
700
+ * `executeAggregate`).
701
+ */
702
+ optionalDependencies: string[];
636
703
  private service?;
637
704
  private readonly options;
638
705
  constructor(options?: AnalyticsServicePluginOptions);
@@ -650,6 +717,50 @@ declare function combineFilters(a?: FilterCondition, b?: FilterCondition): Filte
650
717
  * Division by zero (and missing operands) yields `null` rather than Infinity/NaN.
651
718
  */
652
719
  declare function evaluateDerivedMeasures(rows: Record<string, unknown>[], derived: DerivedMeasureSpec[]): Record<string, unknown>[];
720
+ /**
721
+ * Fill the EMPTY-GROUP value into every measure column the assembled grid
722
+ * LISTS but no query REPORTED — by aggregate kind (#4708, objectui#3136).
723
+ *
724
+ * The grid is assembled from several results: the primary query, one
725
+ * supplementary query per measure-scoped filter, and (for `compareTo`) a
726
+ * shifted pass. {@link mergeByDimensions} writes a measure's column only onto
727
+ * rows its source result returned, and a `GROUP BY` over a filtered row set
728
+ * emits NO group at all for a dimension value the filter excludes entirely.
729
+ * The column therefore comes back **absent**, not `0` — and absent renders as
730
+ * "no data for this row", which for a count is the opposite of what the row
731
+ * means. A derived ratio over it goes null as well ({@link computeDerived}
732
+ * treats a missing operand as unknowable), so the blank spreads.
733
+ *
734
+ * The bias runs the worst possible way: the rows that blank are the ones whose
735
+ * numerator the filter excluded — the WORST-performing rows. A `lead_source`
736
+ * that won nothing renders as "no data" while one that won everything renders
737
+ * fine.
738
+ *
739
+ * **Filled strictly by aggregate kind**, never wholesale. `count` /
740
+ * `count_distinct` over an excluded group is unambiguously `0` ("how many rows
741
+ * matched" has an exact answer when the answer is none), and `sum` over the
742
+ * empty set is its identity `0`. `avg` / `min` / `max` are genuinely null —
743
+ * there is nothing to average — and flattening those to `0` would trade this
744
+ * lie for the opposite one, reporting a measurement nobody made. The
745
+ * kind→identity mapping is `emptyGroupValueFor` in `@objectstack/spec/data`,
746
+ * shared with the authoring-side coherence checks so the two cannot drift.
747
+ *
748
+ * **Only rows that already exist are touched** — no group is invented. A
749
+ * dimension value no query reported at all has genuinely no data and stays out
750
+ * of the grid; this fills the cell, never the row.
751
+ *
752
+ * Deliberately NOT a `?? 0` in the widget or a `coalesce` in the measure: a
753
+ * consumer-side patch must be repeated by every author of every ratio widget
754
+ * forever, and forgetting it is silent. Only the executor knows which aggregate
755
+ * produced the gap, so only the executor can tell `0` from unknown.
756
+ *
757
+ * Mutates `rows` in place (they are already this pipeline's own copies) and
758
+ * returns them for chaining.
759
+ *
760
+ * @param columnAggregates - Grid column → the aggregate that produced it.
761
+ * Includes `<measure>__compare` columns, which merge through the same seam.
762
+ */
763
+ declare function fillEmptyGroups(rows: Record<string, unknown>[], columnAggregates: Record<string, string | undefined>): Record<string, unknown>[];
653
764
  /** Compute the comparison window for a [start,end] range. */
654
765
  declare function shiftRange(range: [string, string], kind: CompareTo['kind']): [string, string];
655
766
  declare class DatasetExecutor {
@@ -673,6 +784,37 @@ declare class DatasetExecutor {
673
784
  */
674
785
  execute(compiledInput: CompiledDataset, selectionInput: DatasetSelection, context?: ExecutionContext): Promise<AnalyticsResult>;
675
786
  private executeSelection;
787
+ /**
788
+ * Run ONE grouped pass over a set of base measures, honouring each measure's
789
+ * own scoped `filter`: the unfiltered measures in a single query, plus one
790
+ * supplementary query per filter-scoped measure, merged back by dimension key.
791
+ *
792
+ * **This is the executor's only implementation of "how a measure filter is
793
+ * applied", and every window goes through it** — the current period, each
794
+ * `totals` subset (which re-enters via `executeSelection`), and the
795
+ * `compareTo` window. Before #4820 the comparison window had its own,
796
+ * simpler answer: one shifted query over all base measures with only the
797
+ * base filter, so `compiled.measureFilters` was never read on that path.
798
+ * `won_count` counted won deals and `won_count__compare` counted every deal,
799
+ * under one label, in adjacent columns. Only measures carrying a filter were
800
+ * wrong — which is what made it survive: the unfiltered ones next to them
801
+ * compared correctly.
802
+ *
803
+ * The caller supplies the `selection` this pass queries under, which is how
804
+ * the comparison window differs at all: same measures, same dimensions, same
805
+ * filters — a `timeDimensions` shifted by {@link shiftRange}. Nothing else
806
+ * about the two passes may drift, because anything that does becomes a
807
+ * discrepancy between two columns the reader is invited to subtract.
808
+ *
809
+ * Cost: one extra query per filter-scoped measure when `compareTo` is set.
810
+ * The alternative — declaring the discrepancy in the response — is not one,
811
+ * since the two columns exist to be directly comparable.
812
+ *
813
+ * @param window - Ordering/window to push into the SQL. Only ever set for a
814
+ * selection the caller proved is a single self-sufficient query; a pass
815
+ * that fans out must return its whole grid for the merge.
816
+ */
817
+ private runMeasurePass;
676
818
  /**
677
819
  * The selected dimensions the compiled cube types as `time`, in selection
678
820
  * order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
@@ -1038,4 +1180,4 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1038
1180
  private buildFieldMeta;
1039
1181
  }
1040
1182
 
1041
- export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type OrderLabelResolver, type RelationshipResolver, type RelationshipTarget, combineFilters, compileDataset, compileScopedFilterToSql, createOrderLabelResolver, evaluateDerivedMeasures, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange, withLabelFetchCache };
1183
+ export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type OrderLabelResolver, type RelationshipResolver, type RelationshipTarget, combineFilters, compileDataset, compileScopedFilterToSql, createOrderLabelResolver, evaluateDerivedMeasures, fillEmptyGroups, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange, withLabelFetchCache };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { IAnalyticsService, Logger, DriverCapabilities, AnalyticsStrategy, AnalyticsQuery, AnalyticsResult, DatasetSelection, CubeMeta, DatasetCompareTo, StrategyContext } from '@objectstack/spec/contracts';
2
- export { AnalyticsStrategy, DatasetSelection, DriverCapabilities, StrategyContext } from '@objectstack/spec/contracts';
1
+ import { IAnalyticsService, Logger, AnalyticsDriverCapabilities, AnalyticsStrategy, AnalyticsQuery, AnalyticsResult, DatasetSelection, CubeMeta, DatasetCompareTo, StrategyContext } from '@objectstack/spec/contracts';
2
+ export { AnalyticsDriverCapabilities, AnalyticsStrategy, DatasetSelection, StrategyContext } from '@objectstack/spec/contracts';
3
3
  import { Cube, FilterCondition } from '@objectstack/spec/data';
4
4
  import { ExecutionContext } from '@objectstack/spec/kernel';
5
5
  import { Dataset } from '@objectstack/spec/ui';
@@ -268,7 +268,7 @@ interface AnalyticsServiceConfig {
268
268
  * Probe driver capabilities for the object that backs a cube.
269
269
  * The service calls this function to decide which strategy can handle a query.
270
270
  */
271
- queryCapabilities?: (cubeName: string) => DriverCapabilities;
271
+ queryCapabilities?: (cubeName: string) => AnalyticsDriverCapabilities;
272
272
  /**
273
273
  * Execute raw SQL on the driver for a given object.
274
274
  * Required for NativeSQLStrategy.
@@ -370,6 +370,26 @@ interface AnalyticsServiceConfig {
370
370
  * always wires it.
371
371
  */
372
372
  isRegisteredObject?: (name: string) => boolean;
373
+ /**
374
+ * [#4437] The FIELD NAMES `objectName` declares, or `undefined` when nothing
375
+ * authoritative can answer.
376
+ *
377
+ * Consulted by {@link AnalyticsService.ensureCube} to validate the SOURCE
378
+ * FIELD a measure resolves to BEFORE any SQL is built. `inferMeasure` maps a
379
+ * suffix convention onto a field name (`ghost_sum` → `SUM(ghost)`) and used
380
+ * to accept any spelling, so a typo'd measure reached the driver as a column
381
+ * and came back as an opaque `500 SQLITE_ERROR` — a driver error class on the
382
+ * wire for a caller-shaped mistake (ADR-0112). The DATA route already refuses
383
+ * the same mistake with a `400 INVALID_FIELD` naming the field (#4315/#4254);
384
+ * this hook is what lets the ANALYTICS route give the same answer.
385
+ *
386
+ * Same tiering as {@link isRegisteredObject}: absence means "skip the check"
387
+ * (registry-less hosts, engine doubles, external datasources whose columns
388
+ * are not mirrored locally). The production bridge in `plugin.ts` wires it
389
+ * from the same schema registry the data path's gate reads, so "which fields
390
+ * exist" has ONE answer across `/data` and `/analytics`.
391
+ */
392
+ getObjectFieldNames?: (objectName: string) => readonly string[] | undefined;
373
393
  /**
374
394
  * ADR-0021 — optional object-graph resolver used when compiling datasets:
375
395
  * `(baseObject, relationshipName) => relatedObjectName | undefined`. When
@@ -377,15 +397,23 @@ interface AnalyticsServiceConfig {
377
397
  */
378
398
  relationshipResolver?: RelationshipResolver;
379
399
  /**
380
- * ADR-0053 currency chain resolve a measure's SOURCE FIELD currency
381
- * metadata so a monetary measure that omits an explicit `currency` falls back
382
- * to the field's declared currency, then the tenant default (`ctx.currency`).
383
- * Returns the source field's `type` and (fixed-mode) `defaultCurrency`;
384
- * `undefined` for an unknown field. Non-`currency` fields never get a code.
400
+ * Resolve the metadata of a dimension's or measure's SOURCE FIELD on the
401
+ * dataset's base object the one seam through which display semantics that
402
+ * live on the field reach the result columns. `undefined` for an unknown
403
+ * field. Feeds three chains:
404
+ *
405
+ * - ADR-0053 currency: a monetary measure that omits an explicit `currency`
406
+ * falls back to the field's declared currency, then the tenant default
407
+ * (`ctx.currency`). Non-`currency` fields never get a code.
408
+ * - Percent scale (objectui#3136): a measure over a `percent` field inherits
409
+ * that field's storage scale via `percentScaleOf`, so a renderer scales by
410
+ * declared metadata instead of guessing from the value.
411
+ * - Date bucketing: a date vs datetime dimension drills by the right bound.
385
412
  */
386
- measureCurrency?: (object: string, field: string) => {
413
+ sourceFieldMeta?: (object: string, field: string) => {
387
414
  type?: string;
388
415
  defaultCurrency?: string;
416
+ max?: number;
389
417
  } | undefined;
390
418
  /** Pre-defined datasets to compile + register at construction (ADR-0021). */
391
419
  datasets?: Dataset[];
@@ -436,13 +464,15 @@ declare class AnalyticsService implements IAnalyticsService {
436
464
  private readonly datasetRegistry;
437
465
  /** Optional object-graph resolver used when compiling datasets. */
438
466
  private readonly relationshipResolver?;
439
- private readonly measureCurrency?;
467
+ private readonly sourceFieldMeta?;
440
468
  /** Optional dimension display-label resolver (select options / lookup names). */
441
469
  private readonly labelResolver?;
442
470
  /** ADR-0037 P3: pending-seed row resolver for draft data preview. */
443
471
  private readonly draftRowsResolver?;
444
472
  /** [#3867] Schema-registry probe gating cube auto-inference. */
445
473
  private readonly isRegisteredObject?;
474
+ /** [#4437] Field-name probe gating measure source-field resolution. */
475
+ private readonly getObjectFieldNames?;
446
476
  /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
447
477
  private warnedNoObjectRegistry;
448
478
  readonly cubeRegistry: CubeRegistry;
@@ -519,6 +549,35 @@ declare class AnalyticsService implements IAnalyticsService {
519
549
  * strategies pick the right aggregation function and field.
520
550
  */
521
551
  private ensureCube;
552
+ /**
553
+ * [#4437] Reject a measure whose SOURCE FIELD the backing object does not
554
+ * have, BEFORE the strategy compiles it into SQL.
555
+ *
556
+ * `inferMeasure` maps a suffix convention onto a field name and has no way to
557
+ * know whether that field exists: `ghost_sum` happily became `SUM(ghost)`, the
558
+ * driver threw `no such column`, and the caller got
559
+ * `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver
560
+ * error class on the wire, and nothing actionable, for what is a plain typo.
561
+ * The DATA route has refused the same mistake with a `400 INVALID_FIELD`
562
+ * naming the field since #4315/#4254; this is the analytics half of that
563
+ * answer, and it is deliberately the SAME envelope (`code`/`field`/`object`/
564
+ * `param`) so one mistake has one shape across both routes.
565
+ *
566
+ * What it checks, and what it deliberately does not:
567
+ *
568
+ * - Only when the cube's `sql` is a bare OBJECT NAME. An authored cube whose
569
+ * `sql` is a real SQL expression has no field list to check against.
570
+ * - Only when {@link AnalyticsServiceConfig.getObjectFieldNames} answers.
571
+ * Absent hook / unknown object → stand down (see the config field's doc).
572
+ * - Only measures whose source is a BARE COLUMN. `count(*)` has no source
573
+ * field, and a dotted reference (`account.industry`) resolves through a
574
+ * join whose target this check cannot see — both pass through untouched.
575
+ * - `id` / `created_at` / `updated_at` are admitted unconditionally, matching
576
+ * the data path's `resolveQueryFields`: they are engine-assigned rather than
577
+ * declared, and a gate stricter than the engine it guards would reject
578
+ * queries that used to work.
579
+ */
580
+ private assertMeasureFields;
522
581
  /**
523
582
  * [#3867] Gate on the cube auto-inference path: a name with no registered
524
583
  * Cube may only be inferred into one if it is a registered object.
@@ -553,7 +612,7 @@ interface AnalyticsServicePluginOptions {
553
612
  * Probe driver capabilities for a given cube.
554
613
  * When omitted, defaults to in-memory only.
555
614
  */
556
- queryCapabilities?: (cubeName: string) => DriverCapabilities;
615
+ queryCapabilities?: (cubeName: string) => AnalyticsDriverCapabilities;
557
616
  /**
558
617
  * Execute raw SQL on a driver. Enables NativeSQLStrategy.
559
618
  */
@@ -633,6 +692,14 @@ declare class AnalyticsServicePlugin implements Plugin {
633
692
  version: string;
634
693
  type: "standard";
635
694
  dependencies: string[];
695
+ /**
696
+ * init() probes the `data` engine ObjectQLPlugin provides for the
697
+ * auto-bridge — order-if-present so the probe verdict is deterministic
698
+ * (ADR-0116, #4471). Soft, not hard: without an engine the plugin
699
+ * degrades on purpose (per-query lazy resolution / explicit
700
+ * `executeAggregate`).
701
+ */
702
+ optionalDependencies: string[];
636
703
  private service?;
637
704
  private readonly options;
638
705
  constructor(options?: AnalyticsServicePluginOptions);
@@ -650,6 +717,50 @@ declare function combineFilters(a?: FilterCondition, b?: FilterCondition): Filte
650
717
  * Division by zero (and missing operands) yields `null` rather than Infinity/NaN.
651
718
  */
652
719
  declare function evaluateDerivedMeasures(rows: Record<string, unknown>[], derived: DerivedMeasureSpec[]): Record<string, unknown>[];
720
+ /**
721
+ * Fill the EMPTY-GROUP value into every measure column the assembled grid
722
+ * LISTS but no query REPORTED — by aggregate kind (#4708, objectui#3136).
723
+ *
724
+ * The grid is assembled from several results: the primary query, one
725
+ * supplementary query per measure-scoped filter, and (for `compareTo`) a
726
+ * shifted pass. {@link mergeByDimensions} writes a measure's column only onto
727
+ * rows its source result returned, and a `GROUP BY` over a filtered row set
728
+ * emits NO group at all for a dimension value the filter excludes entirely.
729
+ * The column therefore comes back **absent**, not `0` — and absent renders as
730
+ * "no data for this row", which for a count is the opposite of what the row
731
+ * means. A derived ratio over it goes null as well ({@link computeDerived}
732
+ * treats a missing operand as unknowable), so the blank spreads.
733
+ *
734
+ * The bias runs the worst possible way: the rows that blank are the ones whose
735
+ * numerator the filter excluded — the WORST-performing rows. A `lead_source`
736
+ * that won nothing renders as "no data" while one that won everything renders
737
+ * fine.
738
+ *
739
+ * **Filled strictly by aggregate kind**, never wholesale. `count` /
740
+ * `count_distinct` over an excluded group is unambiguously `0` ("how many rows
741
+ * matched" has an exact answer when the answer is none), and `sum` over the
742
+ * empty set is its identity `0`. `avg` / `min` / `max` are genuinely null —
743
+ * there is nothing to average — and flattening those to `0` would trade this
744
+ * lie for the opposite one, reporting a measurement nobody made. The
745
+ * kind→identity mapping is `emptyGroupValueFor` in `@objectstack/spec/data`,
746
+ * shared with the authoring-side coherence checks so the two cannot drift.
747
+ *
748
+ * **Only rows that already exist are touched** — no group is invented. A
749
+ * dimension value no query reported at all has genuinely no data and stays out
750
+ * of the grid; this fills the cell, never the row.
751
+ *
752
+ * Deliberately NOT a `?? 0` in the widget or a `coalesce` in the measure: a
753
+ * consumer-side patch must be repeated by every author of every ratio widget
754
+ * forever, and forgetting it is silent. Only the executor knows which aggregate
755
+ * produced the gap, so only the executor can tell `0` from unknown.
756
+ *
757
+ * Mutates `rows` in place (they are already this pipeline's own copies) and
758
+ * returns them for chaining.
759
+ *
760
+ * @param columnAggregates - Grid column → the aggregate that produced it.
761
+ * Includes `<measure>__compare` columns, which merge through the same seam.
762
+ */
763
+ declare function fillEmptyGroups(rows: Record<string, unknown>[], columnAggregates: Record<string, string | undefined>): Record<string, unknown>[];
653
764
  /** Compute the comparison window for a [start,end] range. */
654
765
  declare function shiftRange(range: [string, string], kind: CompareTo['kind']): [string, string];
655
766
  declare class DatasetExecutor {
@@ -673,6 +784,37 @@ declare class DatasetExecutor {
673
784
  */
674
785
  execute(compiledInput: CompiledDataset, selectionInput: DatasetSelection, context?: ExecutionContext): Promise<AnalyticsResult>;
675
786
  private executeSelection;
787
+ /**
788
+ * Run ONE grouped pass over a set of base measures, honouring each measure's
789
+ * own scoped `filter`: the unfiltered measures in a single query, plus one
790
+ * supplementary query per filter-scoped measure, merged back by dimension key.
791
+ *
792
+ * **This is the executor's only implementation of "how a measure filter is
793
+ * applied", and every window goes through it** — the current period, each
794
+ * `totals` subset (which re-enters via `executeSelection`), and the
795
+ * `compareTo` window. Before #4820 the comparison window had its own,
796
+ * simpler answer: one shifted query over all base measures with only the
797
+ * base filter, so `compiled.measureFilters` was never read on that path.
798
+ * `won_count` counted won deals and `won_count__compare` counted every deal,
799
+ * under one label, in adjacent columns. Only measures carrying a filter were
800
+ * wrong — which is what made it survive: the unfiltered ones next to them
801
+ * compared correctly.
802
+ *
803
+ * The caller supplies the `selection` this pass queries under, which is how
804
+ * the comparison window differs at all: same measures, same dimensions, same
805
+ * filters — a `timeDimensions` shifted by {@link shiftRange}. Nothing else
806
+ * about the two passes may drift, because anything that does becomes a
807
+ * discrepancy between two columns the reader is invited to subtract.
808
+ *
809
+ * Cost: one extra query per filter-scoped measure when `compareTo` is set.
810
+ * The alternative — declaring the discrepancy in the response — is not one,
811
+ * since the two columns exist to be directly comparable.
812
+ *
813
+ * @param window - Ordering/window to push into the SQL. Only ever set for a
814
+ * selection the caller proved is a single self-sufficient query; a pass
815
+ * that fans out must return its whole grid for the merge.
816
+ */
817
+ private runMeasurePass;
676
818
  /**
677
819
  * The selected dimensions the compiled cube types as `time`, in selection
678
820
  * order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
@@ -1038,4 +1180,4 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1038
1180
  private buildFieldMeta;
1039
1181
  }
1040
1182
 
1041
- export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type OrderLabelResolver, type RelationshipResolver, type RelationshipTarget, combineFilters, compileDataset, compileScopedFilterToSql, createOrderLabelResolver, evaluateDerivedMeasures, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange, withLabelFetchCache };
1183
+ export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type OrderLabelResolver, type RelationshipResolver, type RelationshipTarget, combineFilters, compileDataset, compileScopedFilterToSql, createOrderLabelResolver, evaluateDerivedMeasures, fillEmptyGroups, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange, withLabelFetchCache };