@objectstack/service-analytics 17.3.0 → 17.4.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.
package/dist/index.d.cts CHANGED
@@ -8,12 +8,26 @@ import { Plugin, PluginContext } from '@objectstack/core';
8
8
  /**
9
9
  * CubeRegistry — Central registry for analytics cube definitions.
10
10
  *
11
- * Cubes can be registered from two sources:
12
- * 1. **Manifest definitions** Explicit cube definitions in `objectstack.config.ts`.
13
- * 2. **Object schema inference** Auto-generated cubes from ObjectQL object schemas.
11
+ * The registry is the single source of truth for cube metadata discovery:
12
+ * `getMeta()` maps every registered cube's measure/dimension `label` onto the
13
+ * `CubeMeta` titles served by `GET /api/v1/analytics/meta`, and the strategy
14
+ * chain resolves a query's cube through it.
14
15
  *
15
- * The registry is the single source of truth for cube metadata discovery
16
- * (used by `getMeta()` and the strategy chain).
16
+ * Three sources write to it, all of them from `AnalyticsService`:
17
+ * 1. **Manifest definitions** — `AnalyticsServiceConfig.cubes` (`registerAll`),
18
+ * i.e. explicit cube definitions authored in `objectstack.config.ts`.
19
+ * 2. **Compiled datasets** (ADR-0021) — `compileDataset()`'s Cube, registered
20
+ * under the dataset's name by `queryDataset`.
21
+ * 3. **Ad-hoc query inference** — `ensureCube` / `inferCubeFromQuery` mints a
22
+ * minimal Cube from the members an `AnalyticsQuery` references, once
23
+ * `assertInferableCube` (#3867) has confirmed the name is a registered
24
+ * object. It infers from the QUERY, never from the object's field schema.
25
+ *
26
+ * This list used to read "two sources: manifest definitions, and object schema
27
+ * inference". Neither half was right: sources 2 and 3 were missing, and object
28
+ * schema inference is `inferFromObject` below, which no path in this repository
29
+ * calls (#15019). It is described at the method rather than advertised here,
30
+ * because listing it would promise a source the platform does not deliver.
17
31
  */
18
32
  declare class CubeRegistry {
19
33
  private cubes;
@@ -34,14 +48,36 @@ declare class CubeRegistry {
34
48
  /** Remove all cubes. */
35
49
  clear(): void;
36
50
  /**
37
- * Auto-generate a cube definition from an object schema.
38
- *
39
- * Heuristic rules:
40
- * - `number` fields `sum`, `avg`, `min`, `max` measures
41
- * - `boolean` fields `count` measure (count where true)
42
- * - All non-computed fields dimensions
43
- * - `date`/`datetime` fields time dimensions with standard granularities
44
- * - A default `count` measure is always added
51
+ * Auto-generate a cube definition from an object's FIELD SCHEMA, and register
52
+ * it under `objectName`.
53
+ *
54
+ * ⚠️ Nothing in this repository calls this the only in-tree caller is a unit
55
+ * test, and every cube the platform registers itself comes from one of the
56
+ * three sources named on the class above (#15019). That is not the same thing
57
+ * as unreachable: `CubeRegistry` is exported from the package entry and
58
+ * `AnalyticsService.cubeRegistry` is public, so a consumer of
59
+ * `@objectstack/service-analytics` can call it, and what it mints does reach
60
+ * the wire — `getMeta()` serves the labels below as `CubeMeta` titles. Whether
61
+ * this published method is removed or wired up as a real cube source is #15019.
62
+ *
63
+ * Heuristic rules, measured by driving the built package (the list this
64
+ * replaces claimed three behaviours the code does not have — `min`/`max`
65
+ * measures, a `count` measure for booleans, and a computed-field exclusion):
66
+ * - `number` / `currency` / `percent` fields → one `sum` and one `avg` measure
67
+ * each, labelled with the field's label plus ` (Sum)` / ` (Avg)`. No `min`
68
+ * or `max` measure is minted.
69
+ * - EVERY field becomes a dimension; there is no computed-field exclusion (the
70
+ * `fields` parameter carries no flag one could exclude on).
71
+ * - `boolean` fields become a `boolean` DIMENSION and nothing else — no count
72
+ * measure is minted for them.
73
+ * - `date` / `datetime` fields → `time` dimensions granulated
74
+ * day/week/month/quarter/year.
75
+ * - A default `count` measure labelled `Count` is always added.
76
+ *
77
+ * Those three defaults (`Count`, and the two composites) are English literals
78
+ * with no i18n hook; #14492's ruling listed the `Count` one as a site to carry
79
+ * the `builtinAggregate` discriminator, and it was left alone because no
80
+ * in-repo path reaches it.
45
81
  *
46
82
  * @param objectName - The snake_case object name (used as table/cube name)
47
83
  * @param fields - Array of field descriptors `{ name, type, label? }`
@@ -520,6 +556,24 @@ interface AnalyticsServiceConfig {
520
556
  defaultCurrency?: string;
521
557
  max?: number;
522
558
  } | undefined;
559
+ /**
560
+ * [#15684] The SQL dialect of the datasource backing `object` — `'sqlite'`,
561
+ * `'postgres'`, `'mysql'`, or `undefined` when the host cannot answer.
562
+ *
563
+ * The three SQL compilers need it for ONE thing: the case-EXACT text family
564
+ * (`$contains` / `$notContains` / `$startsWith` / `$endsWith`, #4706 Q2 = A)
565
+ * has no construct that is case-exact and parses on every dialect. A plain
566
+ * `LIKE` is case-exact on Postgres alone — SQLite folds ASCII case
567
+ * unconditionally, so the query's own `where` AND the RLS read scope
568
+ * admitted rows the predicate excludes there (over-reach, #3948). The
569
+ * per-dialect table lives in `text-match-sql.ts`.
570
+ *
571
+ * Answered by the plugin from the driver that will EXECUTE the statement, so
572
+ * the driver stays the single source of truth for its own dialect. A host
573
+ * that wires nothing keeps the `LIKE` the compilers always emitted —
574
+ * "cannot answer, do not block".
575
+ */
576
+ sqlDialect?: (object: string) => string | undefined;
523
577
  /** Pre-defined datasets to compile + register at construction (ADR-0021). */
524
578
  datasets?: Dataset[];
525
579
  /**
@@ -718,6 +772,42 @@ declare class AnalyticsService implements IAnalyticsService {
718
772
  queryDataset(dataset: Dataset, selection: DatasetSelection, context?: ExecutionContext, options?: {
719
773
  previewDrafts?: boolean;
720
774
  }): Promise<AnalyticsResult>;
775
+ /**
776
+ * The dataset dimensions this selection GROUPED THE GRID BY, resolved against
777
+ * the dataset definition. Shared by drill metadata, row-value label
778
+ * resolution and — through {@link enrichResultColumns} — the dimension column
779
+ * headers, so all three answer "which dimensions" the same way.
780
+ */
781
+ private selectedDimensions;
782
+ /**
783
+ * ADR-0021 — describe the result's COLUMNS from the dataset's own authored
784
+ * definition: a measure's `label` / `format` / `currency` / `percentScale` /
785
+ * `builtinAggregate` and the `type` its aggregate really returns, then a
786
+ * dimension column's header `label`.
787
+ *
788
+ * **Every key here is read off the DATASET** (the authored measure or
789
+ * dimension) **and `sourceFieldMeta`** (the source object's declared field
790
+ * metadata). Not one is read off `result.rows`. That is what makes this one
791
+ * seam serve both paths that produce a dataset response — the live engine
792
+ * query and the ADR-0037 P3 draft-data preview — and it is why #16097 was a
793
+ * defect rather than a deliberate omission: the preview branch returns ~250
794
+ * lines before this ran, so a response over drafted seed rows carried none of
795
+ * these keys and a renderer fell back to humanizing the raw measure name and
796
+ * guessing a percent scale from magnitude — the exact failures #5537,
797
+ * objectui#3136 and #14492 each closed on the live path.
798
+ *
799
+ * Extracted rather than copied onto the second path, for the reason the
800
+ * `type` correction below already gives for living here at all: this is ONE
801
+ * rule holding both halves of the question, and a per-path copy would be two
802
+ * implementations of it, free to drift.
803
+ *
804
+ * ⛔ Not here, and deliberately: dimension VALUE label resolution
805
+ * ({@link resolveDimensionLabels}), which rewrites the grouped value in each
806
+ * ROW. That reads the rows, it is the one enrichment a seed-draft row set can
807
+ * make unnecessary, and the preview path skips it on purpose — see the note
808
+ * at that early return.
809
+ */
810
+ private enrichResultColumns;
721
811
  /**
722
812
  * Get cube metadata for discovery.
723
813
  */
@@ -1221,7 +1311,37 @@ declare class DatasetExecutor {
1221
1311
  */
1222
1312
  declare function mergeByDimensions(base: Record<string, unknown>[], extra: Record<string, unknown>[], dimensions: string[], valueColumns: string[]): Record<string, unknown>[];
1223
1313
 
1224
- declare function compileScopedFilterToSql(filter: FilterCondition, alias: string): {
1314
+ /**
1315
+ * [#14079] What a caller can tell this compiler about the columns a read scope
1316
+ * names, beyond the scope itself.
1317
+ */
1318
+ interface ReadScopeCompileOptions {
1319
+ /**
1320
+ * Is `field` a column whose STORED value is never text — a declared numeric
1321
+ * or boolean scalar (`NON_TEXT_STORED_VALUE_TYPES`, `@objectstack/spec`)?
1322
+ * When it answers `true`, a text operator over that column compiles to the
1323
+ * contract's constant (`1 = 0` for the positive operators, `1 = 1` for
1324
+ * `$notContains`) instead of a `LIKE` that coerces on SQLite and is refused
1325
+ * at query time on Postgres (SQLSTATE 42883). Absent, or answering `false`,
1326
+ * keeps the `LIKE` — the answer for a text column and for a caller that
1327
+ * cannot classify one (`non-text-column.ts` carries the argument).
1328
+ */
1329
+ nonTextColumn?: (field: string) => boolean;
1330
+ /**
1331
+ * [#15684] The SQL dialect that will EXECUTE this scope — `'sqlite'` /
1332
+ * `'postgres'` / `'mysql'`. Anything else, including absent, is `'unknown'`
1333
+ * and keeps the plain `LIKE` this compiler always emitted.
1334
+ *
1335
+ * The case-EXACT text family (#4706 Q2 = A) needs it: SQLite's `LIKE` folds
1336
+ * ASCII case unconditionally, so a policy written `{ name: { $contains:
1337
+ * 'acme' } }` ADMITTED `ACME Corp` there — rows the predicate excludes,
1338
+ * which on a read scope is over-reach (#3948), not a loose filter. The
1339
+ * per-dialect construct table lives in `text-match-sql.ts`; both of this
1340
+ * compiler's consumers fill this in from the driver that owns the object.
1341
+ */
1342
+ dialect?: string;
1343
+ }
1344
+ declare function compileScopedFilterToSql(filter: FilterCondition, alias: string, options?: ReadScopeCompileOptions): {
1225
1345
  sql: string;
1226
1346
  params: unknown[];
1227
1347
  };
@@ -1702,6 +1822,17 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1702
1822
  * 3. `<field>` — bare column on the cube's table.
1703
1823
  */
1704
1824
  private lookupMember;
1825
+ /**
1826
+ * [#14079] The (object, field) a filter member binds against — the echo's
1827
+ * copy of `NativeSQLStrategy.resolveStorageTarget`, kept beside the
1828
+ * `LIKE_SQL_OPS` table for the same reason that table is a copy: this file
1829
+ * renders a description of the statement THAT compiler produces, and the
1830
+ * declared-type test both apply is keyed by object and field. A dotted
1831
+ * `sql` is a relationship path (ADR-0071): every segment but the last is a
1832
+ * hop whose join alias is the dot-to-`__` spelling the dataset compiler keys
1833
+ * `cube.joins` by, the last is the column.
1834
+ */
1835
+ private resolveStorageTarget;
1705
1836
  private resolveFieldName;
1706
1837
  private resolveMeasureAggregation;
1707
1838
  /**
package/dist/index.d.ts CHANGED
@@ -8,12 +8,26 @@ import { Plugin, PluginContext } from '@objectstack/core';
8
8
  /**
9
9
  * CubeRegistry — Central registry for analytics cube definitions.
10
10
  *
11
- * Cubes can be registered from two sources:
12
- * 1. **Manifest definitions** Explicit cube definitions in `objectstack.config.ts`.
13
- * 2. **Object schema inference** Auto-generated cubes from ObjectQL object schemas.
11
+ * The registry is the single source of truth for cube metadata discovery:
12
+ * `getMeta()` maps every registered cube's measure/dimension `label` onto the
13
+ * `CubeMeta` titles served by `GET /api/v1/analytics/meta`, and the strategy
14
+ * chain resolves a query's cube through it.
14
15
  *
15
- * The registry is the single source of truth for cube metadata discovery
16
- * (used by `getMeta()` and the strategy chain).
16
+ * Three sources write to it, all of them from `AnalyticsService`:
17
+ * 1. **Manifest definitions** — `AnalyticsServiceConfig.cubes` (`registerAll`),
18
+ * i.e. explicit cube definitions authored in `objectstack.config.ts`.
19
+ * 2. **Compiled datasets** (ADR-0021) — `compileDataset()`'s Cube, registered
20
+ * under the dataset's name by `queryDataset`.
21
+ * 3. **Ad-hoc query inference** — `ensureCube` / `inferCubeFromQuery` mints a
22
+ * minimal Cube from the members an `AnalyticsQuery` references, once
23
+ * `assertInferableCube` (#3867) has confirmed the name is a registered
24
+ * object. It infers from the QUERY, never from the object's field schema.
25
+ *
26
+ * This list used to read "two sources: manifest definitions, and object schema
27
+ * inference". Neither half was right: sources 2 and 3 were missing, and object
28
+ * schema inference is `inferFromObject` below, which no path in this repository
29
+ * calls (#15019). It is described at the method rather than advertised here,
30
+ * because listing it would promise a source the platform does not deliver.
17
31
  */
18
32
  declare class CubeRegistry {
19
33
  private cubes;
@@ -34,14 +48,36 @@ declare class CubeRegistry {
34
48
  /** Remove all cubes. */
35
49
  clear(): void;
36
50
  /**
37
- * Auto-generate a cube definition from an object schema.
38
- *
39
- * Heuristic rules:
40
- * - `number` fields `sum`, `avg`, `min`, `max` measures
41
- * - `boolean` fields `count` measure (count where true)
42
- * - All non-computed fields dimensions
43
- * - `date`/`datetime` fields time dimensions with standard granularities
44
- * - A default `count` measure is always added
51
+ * Auto-generate a cube definition from an object's FIELD SCHEMA, and register
52
+ * it under `objectName`.
53
+ *
54
+ * ⚠️ Nothing in this repository calls this the only in-tree caller is a unit
55
+ * test, and every cube the platform registers itself comes from one of the
56
+ * three sources named on the class above (#15019). That is not the same thing
57
+ * as unreachable: `CubeRegistry` is exported from the package entry and
58
+ * `AnalyticsService.cubeRegistry` is public, so a consumer of
59
+ * `@objectstack/service-analytics` can call it, and what it mints does reach
60
+ * the wire — `getMeta()` serves the labels below as `CubeMeta` titles. Whether
61
+ * this published method is removed or wired up as a real cube source is #15019.
62
+ *
63
+ * Heuristic rules, measured by driving the built package (the list this
64
+ * replaces claimed three behaviours the code does not have — `min`/`max`
65
+ * measures, a `count` measure for booleans, and a computed-field exclusion):
66
+ * - `number` / `currency` / `percent` fields → one `sum` and one `avg` measure
67
+ * each, labelled with the field's label plus ` (Sum)` / ` (Avg)`. No `min`
68
+ * or `max` measure is minted.
69
+ * - EVERY field becomes a dimension; there is no computed-field exclusion (the
70
+ * `fields` parameter carries no flag one could exclude on).
71
+ * - `boolean` fields become a `boolean` DIMENSION and nothing else — no count
72
+ * measure is minted for them.
73
+ * - `date` / `datetime` fields → `time` dimensions granulated
74
+ * day/week/month/quarter/year.
75
+ * - A default `count` measure labelled `Count` is always added.
76
+ *
77
+ * Those three defaults (`Count`, and the two composites) are English literals
78
+ * with no i18n hook; #14492's ruling listed the `Count` one as a site to carry
79
+ * the `builtinAggregate` discriminator, and it was left alone because no
80
+ * in-repo path reaches it.
45
81
  *
46
82
  * @param objectName - The snake_case object name (used as table/cube name)
47
83
  * @param fields - Array of field descriptors `{ name, type, label? }`
@@ -520,6 +556,24 @@ interface AnalyticsServiceConfig {
520
556
  defaultCurrency?: string;
521
557
  max?: number;
522
558
  } | undefined;
559
+ /**
560
+ * [#15684] The SQL dialect of the datasource backing `object` — `'sqlite'`,
561
+ * `'postgres'`, `'mysql'`, or `undefined` when the host cannot answer.
562
+ *
563
+ * The three SQL compilers need it for ONE thing: the case-EXACT text family
564
+ * (`$contains` / `$notContains` / `$startsWith` / `$endsWith`, #4706 Q2 = A)
565
+ * has no construct that is case-exact and parses on every dialect. A plain
566
+ * `LIKE` is case-exact on Postgres alone — SQLite folds ASCII case
567
+ * unconditionally, so the query's own `where` AND the RLS read scope
568
+ * admitted rows the predicate excludes there (over-reach, #3948). The
569
+ * per-dialect table lives in `text-match-sql.ts`.
570
+ *
571
+ * Answered by the plugin from the driver that will EXECUTE the statement, so
572
+ * the driver stays the single source of truth for its own dialect. A host
573
+ * that wires nothing keeps the `LIKE` the compilers always emitted —
574
+ * "cannot answer, do not block".
575
+ */
576
+ sqlDialect?: (object: string) => string | undefined;
523
577
  /** Pre-defined datasets to compile + register at construction (ADR-0021). */
524
578
  datasets?: Dataset[];
525
579
  /**
@@ -718,6 +772,42 @@ declare class AnalyticsService implements IAnalyticsService {
718
772
  queryDataset(dataset: Dataset, selection: DatasetSelection, context?: ExecutionContext, options?: {
719
773
  previewDrafts?: boolean;
720
774
  }): Promise<AnalyticsResult>;
775
+ /**
776
+ * The dataset dimensions this selection GROUPED THE GRID BY, resolved against
777
+ * the dataset definition. Shared by drill metadata, row-value label
778
+ * resolution and — through {@link enrichResultColumns} — the dimension column
779
+ * headers, so all three answer "which dimensions" the same way.
780
+ */
781
+ private selectedDimensions;
782
+ /**
783
+ * ADR-0021 — describe the result's COLUMNS from the dataset's own authored
784
+ * definition: a measure's `label` / `format` / `currency` / `percentScale` /
785
+ * `builtinAggregate` and the `type` its aggregate really returns, then a
786
+ * dimension column's header `label`.
787
+ *
788
+ * **Every key here is read off the DATASET** (the authored measure or
789
+ * dimension) **and `sourceFieldMeta`** (the source object's declared field
790
+ * metadata). Not one is read off `result.rows`. That is what makes this one
791
+ * seam serve both paths that produce a dataset response — the live engine
792
+ * query and the ADR-0037 P3 draft-data preview — and it is why #16097 was a
793
+ * defect rather than a deliberate omission: the preview branch returns ~250
794
+ * lines before this ran, so a response over drafted seed rows carried none of
795
+ * these keys and a renderer fell back to humanizing the raw measure name and
796
+ * guessing a percent scale from magnitude — the exact failures #5537,
797
+ * objectui#3136 and #14492 each closed on the live path.
798
+ *
799
+ * Extracted rather than copied onto the second path, for the reason the
800
+ * `type` correction below already gives for living here at all: this is ONE
801
+ * rule holding both halves of the question, and a per-path copy would be two
802
+ * implementations of it, free to drift.
803
+ *
804
+ * ⛔ Not here, and deliberately: dimension VALUE label resolution
805
+ * ({@link resolveDimensionLabels}), which rewrites the grouped value in each
806
+ * ROW. That reads the rows, it is the one enrichment a seed-draft row set can
807
+ * make unnecessary, and the preview path skips it on purpose — see the note
808
+ * at that early return.
809
+ */
810
+ private enrichResultColumns;
721
811
  /**
722
812
  * Get cube metadata for discovery.
723
813
  */
@@ -1221,7 +1311,37 @@ declare class DatasetExecutor {
1221
1311
  */
1222
1312
  declare function mergeByDimensions(base: Record<string, unknown>[], extra: Record<string, unknown>[], dimensions: string[], valueColumns: string[]): Record<string, unknown>[];
1223
1313
 
1224
- declare function compileScopedFilterToSql(filter: FilterCondition, alias: string): {
1314
+ /**
1315
+ * [#14079] What a caller can tell this compiler about the columns a read scope
1316
+ * names, beyond the scope itself.
1317
+ */
1318
+ interface ReadScopeCompileOptions {
1319
+ /**
1320
+ * Is `field` a column whose STORED value is never text — a declared numeric
1321
+ * or boolean scalar (`NON_TEXT_STORED_VALUE_TYPES`, `@objectstack/spec`)?
1322
+ * When it answers `true`, a text operator over that column compiles to the
1323
+ * contract's constant (`1 = 0` for the positive operators, `1 = 1` for
1324
+ * `$notContains`) instead of a `LIKE` that coerces on SQLite and is refused
1325
+ * at query time on Postgres (SQLSTATE 42883). Absent, or answering `false`,
1326
+ * keeps the `LIKE` — the answer for a text column and for a caller that
1327
+ * cannot classify one (`non-text-column.ts` carries the argument).
1328
+ */
1329
+ nonTextColumn?: (field: string) => boolean;
1330
+ /**
1331
+ * [#15684] The SQL dialect that will EXECUTE this scope — `'sqlite'` /
1332
+ * `'postgres'` / `'mysql'`. Anything else, including absent, is `'unknown'`
1333
+ * and keeps the plain `LIKE` this compiler always emitted.
1334
+ *
1335
+ * The case-EXACT text family (#4706 Q2 = A) needs it: SQLite's `LIKE` folds
1336
+ * ASCII case unconditionally, so a policy written `{ name: { $contains:
1337
+ * 'acme' } }` ADMITTED `ACME Corp` there — rows the predicate excludes,
1338
+ * which on a read scope is over-reach (#3948), not a loose filter. The
1339
+ * per-dialect construct table lives in `text-match-sql.ts`; both of this
1340
+ * compiler's consumers fill this in from the driver that owns the object.
1341
+ */
1342
+ dialect?: string;
1343
+ }
1344
+ declare function compileScopedFilterToSql(filter: FilterCondition, alias: string, options?: ReadScopeCompileOptions): {
1225
1345
  sql: string;
1226
1346
  params: unknown[];
1227
1347
  };
@@ -1702,6 +1822,17 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1702
1822
  * 3. `<field>` — bare column on the cube's table.
1703
1823
  */
1704
1824
  private lookupMember;
1825
+ /**
1826
+ * [#14079] The (object, field) a filter member binds against — the echo's
1827
+ * copy of `NativeSQLStrategy.resolveStorageTarget`, kept beside the
1828
+ * `LIKE_SQL_OPS` table for the same reason that table is a copy: this file
1829
+ * renders a description of the statement THAT compiler produces, and the
1830
+ * declared-type test both apply is keyed by object and field. A dotted
1831
+ * `sql` is a relationship path (ADR-0071): every segment but the last is a
1832
+ * hop whose join alias is the dot-to-`__` spelling the dataset compiler keys
1833
+ * `cube.joins` by, the last is the column.
1834
+ */
1835
+ private resolveStorageTarget;
1705
1836
  private resolveFieldName;
1706
1837
  private resolveMeasureAggregation;
1707
1838
  /**