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

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
@@ -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
@@ -617,6 +625,11 @@ interface AnalyticsServicePluginOptions {
617
625
  */
618
626
  declare class AnalyticsServicePlugin implements Plugin {
619
627
  name: string;
628
+ /**
629
+ * Services init() registers on every path (ADR-0116, #4131) — lets the
630
+ * kernel name this plugin when a consumer requires one before it inits.
631
+ */
632
+ providesServices: string[];
620
633
  version: string;
621
634
  type: "standard";
622
635
  dependencies: string[];
@@ -660,6 +673,17 @@ declare class DatasetExecutor {
660
673
  */
661
674
  execute(compiledInput: CompiledDataset, selectionInput: DatasetSelection, context?: ExecutionContext): Promise<AnalyticsResult>;
662
675
  private executeSelection;
676
+ /**
677
+ * The selected dimensions the compiled cube types as `time`, in selection
678
+ * order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
679
+ *
680
+ * Membership is decided by the DIMENSION's declared type, not by whether the
681
+ * selection happens to bucket it: a `date` dimension left ungranulated groups
682
+ * raw timestamps, and those want chronological order every bit as much as
683
+ * month buckets do. (Both sort correctly — `compareValues` compares Dates and
684
+ * ISO strings chronologically, and bucket keys are minted sort-stable.)
685
+ */
686
+ private timeDimensionsOf;
663
687
  private buildQuery;
664
688
  private runCompare;
665
689
  }
@@ -681,6 +705,12 @@ declare function compileScopedFilterToSql(filter: FilterCondition, alias: string
681
705
  * Pushes the analytics query down to the database as a native SQL statement.
682
706
  * This is the most efficient path and is preferred whenever the backing driver
683
707
  * supports raw SQL execution (e.g. Postgres, MySQL, SQLite).
708
+ *
709
+ * `resolveMeasureSql` used to answer `COUNT(*)` to three different questions it
710
+ * could not otherwise answer — an undeclared measure, a custom-SQL-expression
711
+ * metric type, and an unrecognised type. All three returned a plausible number
712
+ * for a query that asked for something else. They now emit the expression or
713
+ * throw; see that method. #4157.
684
714
  */
685
715
  declare class NativeSQLStrategy implements AnalyticsStrategy {
686
716
  readonly name = "NativeSQLStrategy";
@@ -767,6 +797,34 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
767
797
  * correctly.
768
798
  */
769
799
  private coerceTemporal;
800
+ /**
801
+ * The column side of {@link coerceTemporal}: normalise the reference so it
802
+ * reads in the storage form the comparand was coerced into.
803
+ *
804
+ * A SQLite `Field.datetime` column carries an INTEGER epoch (a `Date` write)
805
+ * and ISO TEXT (a REST/JSON write, a `NOW()` default — including the platform's
806
+ * own `created_at`) at the SAME time, so coercing the value alone fixes one half
807
+ * and empties the other. That is #3912: a `dateRange: last_30_days` on
808
+ * `created_date` read 0 with 29 rows in range. Every other column and dialect
809
+ * gets its reference back verbatim.
810
+ */
811
+ private temporalColumn;
812
+ /**
813
+ * Compile a normalized filter node into a boolean SQL expression, recursing
814
+ * through the combinators. `null` = no constraint.
815
+ *
816
+ * Leaves go through {@link buildFilterClause} exactly as they did when this
817
+ * was a flat loop, so the storage-form coercion and the calendar-day
818
+ * upper-bound rule (#3777) apply at every depth — including inside an `$or`,
819
+ * where a second, combinator-aware implementation would have been free to
820
+ * drift from the first.
821
+ *
822
+ * Parenthesisation is explicit rather than left to SQL's precedence: `AND`
823
+ * does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
824
+ * being right by construction is what keeps a future edit from making it
825
+ * wrong.
826
+ */
827
+ private compileFilterNode;
770
828
  private buildFilterClause;
771
829
  private extractObjectName;
772
830
  private buildFieldMeta;
@@ -895,6 +953,26 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
895
953
  * are handed back for the caller to AND in separately, so the engine
896
954
  * intersects them instead of the strategy picking a winner.
897
955
  */
956
+ /**
957
+ * Fold a normalized filter node into the engine filter being built.
958
+ *
959
+ * AND-ed LEAVES merge per field through {@link mergeFilterOperand}, exactly
960
+ * as the flat loop this replaced did — so a query without combinators still
961
+ * produces byte-identical engine input. Anything structural (`$or`, `$not`,
962
+ * a nested `$and` that cannot merge) becomes its own conjunct, which the
963
+ * caller ANDs in. The engine speaks these combinators natively
964
+ * (`FilterCondition` declares them and every driver compiles them), so this
965
+ * path hands them over rather than lowering them.
966
+ */
967
+ private applyFilterNode;
968
+ /** A node as a standalone `FilterCondition` the engine can consume. */
969
+ private filterNodeToCondition;
970
+ /**
971
+ * Render a normalized filter node as the display SQL `/analytics/sql`
972
+ * echoes. Values still bind as `$n` placeholders — the echo travels to the
973
+ * browser, so a comparand is never inlined.
974
+ */
975
+ private renderFilterNodeSql;
898
976
  private mergeFilterOperand;
899
977
  /**
900
978
  * Lower `timeDimensions[].dateRange` into resolved-field bounds (#3650).
@@ -907,9 +985,13 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
907
985
  * HERE on every driver — and "bucketed trend" is precisely the shape that also
908
986
  * carries a range ("last 12 months", "this quarter").
909
987
  *
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.
988
+ * Bounds are inclusive on both ends — logically "from day X through day Y".
989
+ * The `$lte` end is left as the bare calendar day on purpose: the driver's
990
+ * filter compiler owns the calendar-day instant translation, compiling a
991
+ * bare-day `$lte` on a `datetime` column into the half-open `< nextDay`
992
+ * (#3777) while a `date` column keeps the plain `<=`. `NativeSQLStrategy`
993
+ * performs the same half-open translation itself because it binds into raw
994
+ * SQL, so one dashboard reads the same on every driver.
913
995
  *
914
996
  * Comparands are coerced by the SAME helper the `where` path uses, so an
915
997
  * epoch-ms bound recovers as a number and an ISO string stays a string. No
@@ -934,6 +1016,25 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
934
1016
  private dateRangeBounds;
935
1017
  private convertFilter;
936
1018
  private extractObjectName;
1019
+ /**
1020
+ * The dimensions this query PROJECTS, in the order the result carries them:
1021
+ * every `dimensions` entry, then every granular `timeDimensions` entry that
1022
+ * is not already one of them.
1023
+ *
1024
+ * `timeDimensions` is not merely a filter carrier. An entry with a
1025
+ * `granularity` is GROUPED BY — see the `td.granularity` sites that build
1026
+ * groupBy here, in `generateSql` and in the cross-object path — so its
1027
+ * bucket is a COLUMN of the result; an entry without one only contributes a
1028
+ * `dateRange` predicate and must NOT be projected.
1029
+ *
1030
+ * Grouping, row mapping and {@link buildFieldMeta} have to agree on exactly
1031
+ * that set. When they did not, a bucketed query returned rows carrying only
1032
+ * the measures and a `fields` list that never mentioned the bucket — a trend
1033
+ * chart got N values and no x-axis (#4033) — even though the SQL had
1034
+ * selected `date_trunc(…) AS "<dim>"` all along. One definition, every
1035
+ * consumer.
1036
+ */
1037
+ private projectedDimensions;
937
1038
  private buildFieldMeta;
938
1039
  }
939
1040
 
package/dist/index.d.ts CHANGED
@@ -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
@@ -617,6 +625,11 @@ interface AnalyticsServicePluginOptions {
617
625
  */
618
626
  declare class AnalyticsServicePlugin implements Plugin {
619
627
  name: string;
628
+ /**
629
+ * Services init() registers on every path (ADR-0116, #4131) — lets the
630
+ * kernel name this plugin when a consumer requires one before it inits.
631
+ */
632
+ providesServices: string[];
620
633
  version: string;
621
634
  type: "standard";
622
635
  dependencies: string[];
@@ -660,6 +673,17 @@ declare class DatasetExecutor {
660
673
  */
661
674
  execute(compiledInput: CompiledDataset, selectionInput: DatasetSelection, context?: ExecutionContext): Promise<AnalyticsResult>;
662
675
  private executeSelection;
676
+ /**
677
+ * The selected dimensions the compiled cube types as `time`, in selection
678
+ * order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
679
+ *
680
+ * Membership is decided by the DIMENSION's declared type, not by whether the
681
+ * selection happens to bucket it: a `date` dimension left ungranulated groups
682
+ * raw timestamps, and those want chronological order every bit as much as
683
+ * month buckets do. (Both sort correctly — `compareValues` compares Dates and
684
+ * ISO strings chronologically, and bucket keys are minted sort-stable.)
685
+ */
686
+ private timeDimensionsOf;
663
687
  private buildQuery;
664
688
  private runCompare;
665
689
  }
@@ -681,6 +705,12 @@ declare function compileScopedFilterToSql(filter: FilterCondition, alias: string
681
705
  * Pushes the analytics query down to the database as a native SQL statement.
682
706
  * This is the most efficient path and is preferred whenever the backing driver
683
707
  * supports raw SQL execution (e.g. Postgres, MySQL, SQLite).
708
+ *
709
+ * `resolveMeasureSql` used to answer `COUNT(*)` to three different questions it
710
+ * could not otherwise answer — an undeclared measure, a custom-SQL-expression
711
+ * metric type, and an unrecognised type. All three returned a plausible number
712
+ * for a query that asked for something else. They now emit the expression or
713
+ * throw; see that method. #4157.
684
714
  */
685
715
  declare class NativeSQLStrategy implements AnalyticsStrategy {
686
716
  readonly name = "NativeSQLStrategy";
@@ -767,6 +797,34 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
767
797
  * correctly.
768
798
  */
769
799
  private coerceTemporal;
800
+ /**
801
+ * The column side of {@link coerceTemporal}: normalise the reference so it
802
+ * reads in the storage form the comparand was coerced into.
803
+ *
804
+ * A SQLite `Field.datetime` column carries an INTEGER epoch (a `Date` write)
805
+ * and ISO TEXT (a REST/JSON write, a `NOW()` default — including the platform's
806
+ * own `created_at`) at the SAME time, so coercing the value alone fixes one half
807
+ * and empties the other. That is #3912: a `dateRange: last_30_days` on
808
+ * `created_date` read 0 with 29 rows in range. Every other column and dialect
809
+ * gets its reference back verbatim.
810
+ */
811
+ private temporalColumn;
812
+ /**
813
+ * Compile a normalized filter node into a boolean SQL expression, recursing
814
+ * through the combinators. `null` = no constraint.
815
+ *
816
+ * Leaves go through {@link buildFilterClause} exactly as they did when this
817
+ * was a flat loop, so the storage-form coercion and the calendar-day
818
+ * upper-bound rule (#3777) apply at every depth — including inside an `$or`,
819
+ * where a second, combinator-aware implementation would have been free to
820
+ * drift from the first.
821
+ *
822
+ * Parenthesisation is explicit rather than left to SQL's precedence: `AND`
823
+ * does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
824
+ * being right by construction is what keeps a future edit from making it
825
+ * wrong.
826
+ */
827
+ private compileFilterNode;
770
828
  private buildFilterClause;
771
829
  private extractObjectName;
772
830
  private buildFieldMeta;
@@ -895,6 +953,26 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
895
953
  * are handed back for the caller to AND in separately, so the engine
896
954
  * intersects them instead of the strategy picking a winner.
897
955
  */
956
+ /**
957
+ * Fold a normalized filter node into the engine filter being built.
958
+ *
959
+ * AND-ed LEAVES merge per field through {@link mergeFilterOperand}, exactly
960
+ * as the flat loop this replaced did — so a query without combinators still
961
+ * produces byte-identical engine input. Anything structural (`$or`, `$not`,
962
+ * a nested `$and` that cannot merge) becomes its own conjunct, which the
963
+ * caller ANDs in. The engine speaks these combinators natively
964
+ * (`FilterCondition` declares them and every driver compiles them), so this
965
+ * path hands them over rather than lowering them.
966
+ */
967
+ private applyFilterNode;
968
+ /** A node as a standalone `FilterCondition` the engine can consume. */
969
+ private filterNodeToCondition;
970
+ /**
971
+ * Render a normalized filter node as the display SQL `/analytics/sql`
972
+ * echoes. Values still bind as `$n` placeholders — the echo travels to the
973
+ * browser, so a comparand is never inlined.
974
+ */
975
+ private renderFilterNodeSql;
898
976
  private mergeFilterOperand;
899
977
  /**
900
978
  * Lower `timeDimensions[].dateRange` into resolved-field bounds (#3650).
@@ -907,9 +985,13 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
907
985
  * HERE on every driver — and "bucketed trend" is precisely the shape that also
908
986
  * carries a range ("last 12 months", "this quarter").
909
987
  *
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.
988
+ * Bounds are inclusive on both ends — logically "from day X through day Y".
989
+ * The `$lte` end is left as the bare calendar day on purpose: the driver's
990
+ * filter compiler owns the calendar-day instant translation, compiling a
991
+ * bare-day `$lte` on a `datetime` column into the half-open `< nextDay`
992
+ * (#3777) while a `date` column keeps the plain `<=`. `NativeSQLStrategy`
993
+ * performs the same half-open translation itself because it binds into raw
994
+ * SQL, so one dashboard reads the same on every driver.
913
995
  *
914
996
  * Comparands are coerced by the SAME helper the `where` path uses, so an
915
997
  * epoch-ms bound recovers as a number and an ISO string stays a string. No
@@ -934,6 +1016,25 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
934
1016
  private dateRangeBounds;
935
1017
  private convertFilter;
936
1018
  private extractObjectName;
1019
+ /**
1020
+ * The dimensions this query PROJECTS, in the order the result carries them:
1021
+ * every `dimensions` entry, then every granular `timeDimensions` entry that
1022
+ * is not already one of them.
1023
+ *
1024
+ * `timeDimensions` is not merely a filter carrier. An entry with a
1025
+ * `granularity` is GROUPED BY — see the `td.granularity` sites that build
1026
+ * groupBy here, in `generateSql` and in the cross-object path — so its
1027
+ * bucket is a COLUMN of the result; an entry without one only contributes a
1028
+ * `dateRange` predicate and must NOT be projected.
1029
+ *
1030
+ * Grouping, row mapping and {@link buildFieldMeta} have to agree on exactly
1031
+ * that set. When they did not, a bucketed query returned rows carrying only
1032
+ * the measures and a `fields` list that never mentioned the bucket — a trend
1033
+ * chart got N values and no x-axis (#4033) — even though the SQL had
1034
+ * selected `date_trunc(…) AS "<dim>"` all along. One definition, every
1035
+ * consumer.
1036
+ */
1037
+ private projectedDimensions;
937
1038
  private buildFieldMeta;
938
1039
  }
939
1040