@objectstack/service-analytics 17.0.0-rc.0 → 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.
@@ -336,6 +336,14 @@ interface AnalyticsServiceConfig {
336
336
  * `StrategyContext.coerceTemporalFilterValue` for the full rationale.
337
337
  */
338
338
  coerceTemporalFilterValue?: (objectName: string, fieldName: string, value: unknown) => unknown;
339
+ /**
340
+ * Normalise the COLUMN side of the same comparison to that storage form — the
341
+ * other half of the fix, needed because a SQLite `Field.datetime` holds both an
342
+ * INTEGER epoch (a `Date` write) and ISO TEXT (a REST/JSON write, a `NOW()`
343
+ * default) at once, so coercing only the comparand matches one of them and
344
+ * misses the other (#3912). See `StrategyContext.coerceTemporalFilterColumn`.
345
+ */
346
+ coerceTemporalFilterColumn?: (objectName: string, fieldName: string, columnSql: string) => string;
339
347
  /**
340
348
  * ADR-0062 D6 — report whether an object is federated (external datasource).
341
349
  * Threaded into the StrategyContext so `NativeSQLStrategy` declines external
@@ -362,6 +370,26 @@ interface AnalyticsServiceConfig {
362
370
  * always wires it.
363
371
  */
364
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;
365
393
  /**
366
394
  * ADR-0021 — optional object-graph resolver used when compiling datasets:
367
395
  * `(baseObject, relationshipName) => relatedObjectName | undefined`. When
@@ -369,15 +397,23 @@ interface AnalyticsServiceConfig {
369
397
  */
370
398
  relationshipResolver?: RelationshipResolver;
371
399
  /**
372
- * ADR-0053 currency chain resolve a measure's SOURCE FIELD currency
373
- * metadata so a monetary measure that omits an explicit `currency` falls back
374
- * to the field's declared currency, then the tenant default (`ctx.currency`).
375
- * Returns the source field's `type` and (fixed-mode) `defaultCurrency`;
376
- * `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.
377
412
  */
378
- measureCurrency?: (object: string, field: string) => {
413
+ sourceFieldMeta?: (object: string, field: string) => {
379
414
  type?: string;
380
415
  defaultCurrency?: string;
416
+ max?: number;
381
417
  } | undefined;
382
418
  /** Pre-defined datasets to compile + register at construction (ADR-0021). */
383
419
  datasets?: Dataset[];
@@ -428,13 +464,15 @@ declare class AnalyticsService implements IAnalyticsService {
428
464
  private readonly datasetRegistry;
429
465
  /** Optional object-graph resolver used when compiling datasets. */
430
466
  private readonly relationshipResolver?;
431
- private readonly measureCurrency?;
467
+ private readonly sourceFieldMeta?;
432
468
  /** Optional dimension display-label resolver (select options / lookup names). */
433
469
  private readonly labelResolver?;
434
470
  /** ADR-0037 P3: pending-seed row resolver for draft data preview. */
435
471
  private readonly draftRowsResolver?;
436
472
  /** [#3867] Schema-registry probe gating cube auto-inference. */
437
473
  private readonly isRegisteredObject?;
474
+ /** [#4437] Field-name probe gating measure source-field resolution. */
475
+ private readonly getObjectFieldNames?;
438
476
  /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
439
477
  private warnedNoObjectRegistry;
440
478
  readonly cubeRegistry: CubeRegistry;
@@ -511,6 +549,35 @@ declare class AnalyticsService implements IAnalyticsService {
511
549
  * strategies pick the right aggregation function and field.
512
550
  */
513
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;
514
581
  /**
515
582
  * [#3867] Gate on the cube auto-inference path: a name with no registered
516
583
  * Cube may only be inferred into one if it is a registered object.
@@ -545,7 +612,7 @@ interface AnalyticsServicePluginOptions {
545
612
  * Probe driver capabilities for a given cube.
546
613
  * When omitted, defaults to in-memory only.
547
614
  */
548
- queryCapabilities?: (cubeName: string) => DriverCapabilities;
615
+ queryCapabilities?: (cubeName: string) => AnalyticsDriverCapabilities;
549
616
  /**
550
617
  * Execute raw SQL on a driver. Enables NativeSQLStrategy.
551
618
  */
@@ -617,9 +684,22 @@ interface AnalyticsServicePluginOptions {
617
684
  */
618
685
  declare class AnalyticsServicePlugin implements Plugin {
619
686
  name: string;
687
+ /**
688
+ * Services init() registers on every path (ADR-0116, #4131) — lets the
689
+ * kernel name this plugin when a consumer requires one before it inits.
690
+ */
691
+ providesServices: string[];
620
692
  version: string;
621
693
  type: "standard";
622
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[];
623
703
  private service?;
624
704
  private readonly options;
625
705
  constructor(options?: AnalyticsServicePluginOptions);
@@ -637,6 +717,50 @@ declare function combineFilters(a?: FilterCondition, b?: FilterCondition): Filte
637
717
  * Division by zero (and missing operands) yields `null` rather than Infinity/NaN.
638
718
  */
639
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>[];
640
764
  /** Compute the comparison window for a [start,end] range. */
641
765
  declare function shiftRange(range: [string, string], kind: CompareTo['kind']): [string, string];
642
766
  declare class DatasetExecutor {
@@ -660,6 +784,48 @@ declare class DatasetExecutor {
660
784
  */
661
785
  execute(compiledInput: CompiledDataset, selectionInput: DatasetSelection, context?: ExecutionContext): Promise<AnalyticsResult>;
662
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;
818
+ /**
819
+ * The selected dimensions the compiled cube types as `time`, in selection
820
+ * order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
821
+ *
822
+ * Membership is decided by the DIMENSION's declared type, not by whether the
823
+ * selection happens to bucket it: a `date` dimension left ungranulated groups
824
+ * raw timestamps, and those want chronological order every bit as much as
825
+ * month buckets do. (Both sort correctly — `compareValues` compares Dates and
826
+ * ISO strings chronologically, and bucket keys are minted sort-stable.)
827
+ */
828
+ private timeDimensionsOf;
663
829
  private buildQuery;
664
830
  private runCompare;
665
831
  }
@@ -681,6 +847,12 @@ declare function compileScopedFilterToSql(filter: FilterCondition, alias: string
681
847
  * Pushes the analytics query down to the database as a native SQL statement.
682
848
  * This is the most efficient path and is preferred whenever the backing driver
683
849
  * supports raw SQL execution (e.g. Postgres, MySQL, SQLite).
850
+ *
851
+ * `resolveMeasureSql` used to answer `COUNT(*)` to three different questions it
852
+ * could not otherwise answer — an undeclared measure, a custom-SQL-expression
853
+ * metric type, and an unrecognised type. All three returned a plausible number
854
+ * for a query that asked for something else. They now emit the expression or
855
+ * throw; see that method. #4157.
684
856
  */
685
857
  declare class NativeSQLStrategy implements AnalyticsStrategy {
686
858
  readonly name = "NativeSQLStrategy";
@@ -767,6 +939,34 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
767
939
  * correctly.
768
940
  */
769
941
  private coerceTemporal;
942
+ /**
943
+ * The column side of {@link coerceTemporal}: normalise the reference so it
944
+ * reads in the storage form the comparand was coerced into.
945
+ *
946
+ * A SQLite `Field.datetime` column carries an INTEGER epoch (a `Date` write)
947
+ * and ISO TEXT (a REST/JSON write, a `NOW()` default — including the platform's
948
+ * own `created_at`) at the SAME time, so coercing the value alone fixes one half
949
+ * and empties the other. That is #3912: a `dateRange: last_30_days` on
950
+ * `created_date` read 0 with 29 rows in range. Every other column and dialect
951
+ * gets its reference back verbatim.
952
+ */
953
+ private temporalColumn;
954
+ /**
955
+ * Compile a normalized filter node into a boolean SQL expression, recursing
956
+ * through the combinators. `null` = no constraint.
957
+ *
958
+ * Leaves go through {@link buildFilterClause} exactly as they did when this
959
+ * was a flat loop, so the storage-form coercion and the calendar-day
960
+ * upper-bound rule (#3777) apply at every depth — including inside an `$or`,
961
+ * where a second, combinator-aware implementation would have been free to
962
+ * drift from the first.
963
+ *
964
+ * Parenthesisation is explicit rather than left to SQL's precedence: `AND`
965
+ * does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
966
+ * being right by construction is what keeps a future edit from making it
967
+ * wrong.
968
+ */
969
+ private compileFilterNode;
770
970
  private buildFilterClause;
771
971
  private extractObjectName;
772
972
  private buildFieldMeta;
@@ -895,6 +1095,26 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
895
1095
  * are handed back for the caller to AND in separately, so the engine
896
1096
  * intersects them instead of the strategy picking a winner.
897
1097
  */
1098
+ /**
1099
+ * Fold a normalized filter node into the engine filter being built.
1100
+ *
1101
+ * AND-ed LEAVES merge per field through {@link mergeFilterOperand}, exactly
1102
+ * as the flat loop this replaced did — so a query without combinators still
1103
+ * produces byte-identical engine input. Anything structural (`$or`, `$not`,
1104
+ * a nested `$and` that cannot merge) becomes its own conjunct, which the
1105
+ * caller ANDs in. The engine speaks these combinators natively
1106
+ * (`FilterCondition` declares them and every driver compiles them), so this
1107
+ * path hands them over rather than lowering them.
1108
+ */
1109
+ private applyFilterNode;
1110
+ /** A node as a standalone `FilterCondition` the engine can consume. */
1111
+ private filterNodeToCondition;
1112
+ /**
1113
+ * Render a normalized filter node as the display SQL `/analytics/sql`
1114
+ * echoes. Values still bind as `$n` placeholders — the echo travels to the
1115
+ * browser, so a comparand is never inlined.
1116
+ */
1117
+ private renderFilterNodeSql;
898
1118
  private mergeFilterOperand;
899
1119
  /**
900
1120
  * Lower `timeDimensions[].dateRange` into resolved-field bounds (#3650).
@@ -907,9 +1127,13 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
907
1127
  * HERE on every driver — and "bucketed trend" is precisely the shape that also
908
1128
  * carries a range ("last 12 months", "this quarter").
909
1129
  *
910
- * Bounds are inclusive on both ends — the same `$gte`/`$lte` pair
911
- * `NativeSQLStrategy` binds as `BETWEEN` and the memory driver builds as a
912
- * `$match`, so one dashboard reads the same on every driver.
1130
+ * Bounds are inclusive on both ends — logically "from day X through day Y".
1131
+ * The `$lte` end is left as the bare calendar day on purpose: the driver's
1132
+ * filter compiler owns the calendar-day instant translation, compiling a
1133
+ * bare-day `$lte` on a `datetime` column into the half-open `< nextDay`
1134
+ * (#3777) while a `date` column keeps the plain `<=`. `NativeSQLStrategy`
1135
+ * performs the same half-open translation itself because it binds into raw
1136
+ * SQL, so one dashboard reads the same on every driver.
913
1137
  *
914
1138
  * Comparands are coerced by the SAME helper the `where` path uses, so an
915
1139
  * epoch-ms bound recovers as a number and an ISO string stays a string. No
@@ -934,7 +1158,26 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
934
1158
  private dateRangeBounds;
935
1159
  private convertFilter;
936
1160
  private extractObjectName;
1161
+ /**
1162
+ * The dimensions this query PROJECTS, in the order the result carries them:
1163
+ * every `dimensions` entry, then every granular `timeDimensions` entry that
1164
+ * is not already one of them.
1165
+ *
1166
+ * `timeDimensions` is not merely a filter carrier. An entry with a
1167
+ * `granularity` is GROUPED BY — see the `td.granularity` sites that build
1168
+ * groupBy here, in `generateSql` and in the cross-object path — so its
1169
+ * bucket is a COLUMN of the result; an entry without one only contributes a
1170
+ * `dateRange` predicate and must NOT be projected.
1171
+ *
1172
+ * Grouping, row mapping and {@link buildFieldMeta} have to agree on exactly
1173
+ * that set. When they did not, a bucketed query returned rows carrying only
1174
+ * the measures and a `fields` list that never mentioned the bucket — a trend
1175
+ * chart got N values and no x-axis (#4033) — even though the SQL had
1176
+ * selected `date_trunc(…) AS "<dim>"` all along. One definition, every
1177
+ * consumer.
1178
+ */
1179
+ private projectedDimensions;
937
1180
  private buildFieldMeta;
938
1181
  }
939
1182
 
940
- 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 };