@objectstack/service-analytics 17.0.0-rc.5 → 17.0.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
@@ -105,23 +105,32 @@ type RelationshipResolver = (baseObject: string, relationshipName: string) => st
105
105
  */
106
106
  interface DatasetCompileOptions {
107
107
  /**
108
- * [#5115] The datasource `objectName` DECLARES (`object.datasource`), or
109
- * `undefined` when nothing authoritative can answer (no data engine, unknown
110
- * object).
108
+ * [#5115] The datasource `objectName` is BOUND to, or `undefined` when nothing
109
+ * authoritative can answer (no data engine, unknown object) — or when nothing
110
+ * binds the object at all and it rides the deployment's default datasource.
111
111
  *
112
112
  * With it the compiler can settle at COMPILE time what #5033 could only
113
113
  * report at QUERY time: a dataset whose join crosses datasources declares a
114
114
  * statement no driver can execute, because the analytics engine lowers the
115
115
  * whole dataset into ONE SQL statement on the base object's datasource.
116
116
  *
117
- * IMPORTANT `'default'` is not an answer. In `ObjectQL.getDriver`'s
118
- * resolution order an explicit `object.datasource` other than `'default'`
119
- * wins outright (step 1); `'default'` is the schema's DEFAULT value and means
120
- * only "no explicit binding", after which routing is decided by
121
- * `datasourceMapping` rules, the ADR-0057 §3.6 lifecycle split, and the
122
- * owning package's `defaultDatasource` none of which are visible from here.
123
- * The compiler therefore treats `'default'`/`undefined` as UNANSWERED. See
124
- * {@link compileDataset}.
117
+ * [#5288] What the host supplies here changed shape, the rule below did not.
118
+ * It used to be the object's DECLARED `datasource` step 1 of the five
119
+ * `ObjectQL.getDriver` routes by so an object placed by a
120
+ * `datasourceMapping` rule, by the ADR-0057 §3.6 lifecycle split, or by its
121
+ * package's `defaultDatasource` answered `'default'` and was read here as
122
+ * unanswered. The built-in host (`plugin.ts`) now asks the engine's own
123
+ * resolver instead, so those three placements ARE visible from here and a join
124
+ * between two objects bound to two different datasources is decidable
125
+ * whichever mechanism bound them.
126
+ *
127
+ * Still deliberately UNANSWERED: `'default'`, and the object that no rule
128
+ * places anywhere. The deployment's default driver keeps its natural name
129
+ * (#3826), so "rides the default" is reported as `undefined` rather than as a
130
+ * name — which means a join from a bound object to a default-riding one stays
131
+ * undecidable here and remains the query-time diagnostic's business (#5288
132
+ * records this boundary; widening it is #5115's follow-up, not this rule's).
133
+ * See {@link compileDataset}.
125
134
  */
126
135
  getObjectDatasource?: (objectName: string) => string | undefined;
127
136
  /**
@@ -407,6 +416,15 @@ interface AnalyticsServiceConfig {
407
416
  * datasources before any query is ever built. Absence keeps the pre-#5115
408
417
  * behaviour exactly ("cannot answer, do not block") — the query-time
409
418
  * diagnostic above stays as the backstop.
419
+ *
420
+ * [#5288] "Bound to" above is the whole contract, and it took until #5288 for
421
+ * the built-in host to honour it: `plugin.ts` answered with the object's
422
+ * DECLARED `datasource` — step 1 of the five `ObjectQL.getDriver` routes by —
423
+ * so an object placed by a `datasourceMapping` rule, by the ADR-0057 §3.6
424
+ * lifecycle split, or by its package's `defaultDatasource` reported
425
+ * `'default'` and sent the message above to the wrong database. It now asks
426
+ * `ObjectQL.resolveEffectiveDatasource`. A custom host owes the same answer:
427
+ * the datasource an object is BOUND to, `undefined` when nothing binds it.
410
428
  */
411
429
  getObjectDatasource?: (objectName: string) => string | undefined;
412
430
  /**
@@ -504,6 +522,42 @@ interface AnalyticsServiceConfig {
504
522
  * across the publish boundary. Reads only; never touches physical tables.
505
523
  */
506
524
  draftRowsResolver?: (objectName: string, context?: ExecutionContext) => Promise<Record<string, unknown>[] | null>;
525
+ /**
526
+ * [#8286] Echo the executed statement back to the CALLER in
527
+ * `AnalyticsResult.sql`. **Off unless this host opts in.**
528
+ *
529
+ * The contract has always declared the echo debug-only —
530
+ * `AnalyticsResultResponseSchema.data.sql` (`spec/api/analytics.zod.ts`) is
531
+ * `optional()` and describes itself as "Executed SQL (if debug enabled)" —
532
+ * but no implementation ever read a debug switch, so every `/analytics/query`
533
+ * response carried the statement, on production deployments included. This
534
+ * field is that switch: declared = enforced, restored at the one seam the
535
+ * response leaves through ({@link AnalyticsService.query}).
536
+ *
537
+ * **Default** — `NODE_ENV === 'development'`, and nothing else. In
538
+ * particular an UNSET `NODE_ENV` counts as production and the echo stays
539
+ * off: that is the maintainer's 2026-08-06 ruling for machine-readable
540
+ * environment answers (see `resolveDiscoveryEnvironment` and the note at
541
+ * `runtime/src/http-dispatcher.ts`), and of the two ways to be wrong,
542
+ * disclosing on a production deployment whose operator forgot the variable
543
+ * is the dangerous one. `os start` forces `NODE_ENV='production'` when
544
+ * unset, `os serve` resolves `NODE_ENV || 'production'`, `os doctor` derives
545
+ * the same expression — this switch now reads the absence the same way.
546
+ *
547
+ * **Why not a request field.** There is none, deliberately: a caller-set
548
+ * debug flag would let any tenant re-open the disclosure on demand, which is
549
+ * the shape of the defect rather than a fix for it. The echo is a HOST
550
+ * decision, and the caller-facing surface for "show me the SQL" already
551
+ * exists as the dedicated dry-run route `/api/v1/analytics/sql`
552
+ * (`generateSql`), which this switch does not touch.
553
+ *
554
+ * **Why not the plugin's `debug` (log) option.** Server-side log verbosity
555
+ * and what travels to a caller are different decisions with different blast
556
+ * radii; folding them together means a support engineer raising log level on
557
+ * a live deployment silently reopens the disclosure. Two switches, named for
558
+ * what they open.
559
+ */
560
+ debugSql?: boolean;
507
561
  }
508
562
  /**
509
563
  * AnalyticsService — Multi-driver analytics orchestrator.
@@ -551,6 +605,11 @@ declare class AnalyticsService implements IAnalyticsService {
551
605
  private readonly isExternalObject?;
552
606
  /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
553
607
  private warnedNoObjectRegistry;
608
+ /**
609
+ * [#8286] Does the executed statement travel back to the caller?
610
+ * See {@link AnalyticsServiceConfig.debugSql} for the switch and its default.
611
+ */
612
+ private readonly debugSql;
554
613
  readonly cubeRegistry: CubeRegistry;
555
614
  private readonly logger;
556
615
  constructor(config?: AnalyticsServiceConfig);
@@ -586,6 +645,25 @@ declare class AnalyticsService implements IAnalyticsService {
586
645
  * Any other error propagates untouched.
587
646
  */
588
647
  query(query: AnalyticsQuery, context?: ExecutionContext): Promise<AnalyticsResult>;
648
+ /**
649
+ * [#8286] Withhold the executed statement unless this host enabled the echo.
650
+ *
651
+ * Applied at {@link query}, which is the response-assembly seam for BOTH
652
+ * faces that serve callers: `/api/v1/analytics/query` calls it directly, and
653
+ * `queryDataset` reaches it through `DatasetExecutor`, so a dataset response
654
+ * inherits the same verdict without a second gate to keep in step.
655
+ * `generateSql` — the dedicated `/api/v1/analytics/sql` dry-run route — is
656
+ * deliberately NOT gated: asking for the statement is that route's entire
657
+ * purpose, and it is the surface a debugging author is meant to use.
658
+ *
659
+ * What the echo disclosed, and why "it is only a table name" understates it:
660
+ * the statement carries the compiled read scope, i.e. the SHAPE of the
661
+ * isolation predicate (`"sys_user"."id" IN ($2, $3, …)` rather than an
662
+ * `organization_id` comparison) plus its bound-parameter arity, which counts
663
+ * the caller's own org membership. No wall was breached by it — the echo is
664
+ * information disclosure, and this is the disclosure closing.
665
+ */
666
+ private applySqlEchoPolicy;
589
667
  /**
590
668
  * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
591
669
  * can be queried by name. Idempotent (re-registering overwrites). Returns the
@@ -665,6 +743,13 @@ declare class AnalyticsService implements IAnalyticsService {
665
743
  * the data path's `resolveQueryFields`: they are engine-assigned rather than
666
744
  * declared, and a gate stricter than the engine it guards would reject
667
745
  * queries that used to work.
746
+ *
747
+ * [#5918] Its `stripPrefix` below is deliberately NOT narrowed the way the two
748
+ * MINTS were. This is a RESOLVER — it mirrors `lookupMember`'s tiers to answer
749
+ * "which Metric will the strategy read", and that tier order did not change.
750
+ * What changed is what can reach it: a dotted measure is now either a
751
+ * `<cube>.` qualifier or a key the cube itself declares, because every other
752
+ * dotted spelling is refused at the mint before this gate runs.
668
753
  */
669
754
  private assertMeasureFields;
670
755
  /**
@@ -868,6 +953,17 @@ interface AnalyticsServicePluginOptions {
868
953
  getAllowedRelationships?: (cubeName: string) => Set<string> | undefined;
869
954
  /** Enable debug logging. */
870
955
  debug?: boolean;
956
+ /**
957
+ * [#8286] Echo the executed statement back to CALLERS in
958
+ * `AnalyticsResult.sql` (`/api/v1/analytics/query`).
959
+ *
960
+ * Distinct from {@link AnalyticsServicePluginOptions.debug} above, which is
961
+ * server-side log verbosity only: raising log level must never widen what
962
+ * travels to a tenant. Default and rationale live with the service config —
963
+ * see `AnalyticsServiceConfig.debugSql`. Undefined here means "no host
964
+ * choice", which the service resolves to development-only.
965
+ */
966
+ debugSql?: boolean;
871
967
  }
872
968
  /**
873
969
  * AnalyticsServicePlugin — Kernel plugin for multi-driver analytics.
@@ -1041,6 +1137,19 @@ declare class DatasetExecutor {
1041
1137
  * ISO strings chronologically, and bucket keys are minted sort-stable.)
1042
1138
  */
1043
1139
  private timeDimensionsOf;
1140
+ /**
1141
+ * The EFFECTIVE bucket size one dimension is grouped at for this selection,
1142
+ * or `undefined` when it is not a date dimension or nothing states a size (in
1143
+ * which case the runtime groups the raw column).
1144
+ *
1145
+ * One definition, two readers, deliberately: {@link buildQuery} uses it to
1146
+ * decide the `GROUP BY`, and {@link runCompare} uses it to realign the
1147
+ * comparison pass's bucket keys (#6007). Those two MUST agree — realigning
1148
+ * `month` keys a query grouped by `quarter` would move every comparison value
1149
+ * onto a bucket that does not exist — and the way to make them agree is to
1150
+ * have one of them, not two that look alike.
1151
+ */
1152
+ private granularityOf;
1044
1153
  private buildQuery;
1045
1154
  private runCompare;
1046
1155
  }
@@ -1077,6 +1186,47 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
1077
1186
  readonly name = "NativeSQLStrategy";
1078
1187
  readonly priority = 10;
1079
1188
  canHandle(query: AnalyticsQuery, ctx: StrategyContext): boolean;
1189
+ /**
1190
+ * [#7598] Does serving this query require the cross-field capability this
1191
+ * strategy declines? See the ruling recorded at {@link canHandle}.
1192
+ *
1193
+ * ⚠️ This and {@link assertNoCrossFieldComparison} read the SAME inputs
1194
+ * through the SAME detector, which is what makes the decline and the
1195
+ * fail-closed backstop unable to drift: a shape one of them recognises is a
1196
+ * shape the other recognises.
1197
+ */
1198
+ private carriesCrossFieldComparison;
1199
+ private crossFieldComparisonIn;
1200
+ /**
1201
+ * [#7598] The fail-closed backstop at the door that BINDS.
1202
+ *
1203
+ * ⚠️ **Unreachable by construction, and kept deliberately** — saying so
1204
+ * because #7598's brief asks that a refusal arm which has become unreachable
1205
+ * be named rather than left to be re-discovered. {@link canHandle} declines
1206
+ * every query this would fire on, and it declines using
1207
+ * {@link crossFieldComparisonIn} — the same walk over the same two inputs —
1208
+ * so `resolveStrategy` cannot hand this strategy a query carrying one.
1209
+ *
1210
+ * It is kept because of what the failure mode is if that ever stops being
1211
+ * true. The defect #7598 measured was not a missing error: it was a SILENT
1212
+ * BIND — `toSqlBindValue` JSON-stringifies the reference object, so the
1213
+ * statement compiled perfectly and compared a column against the text
1214
+ * `{"$field":"budget"}`, a value no row can hold. A routing gate that misses
1215
+ * a shape therefore degrades to a wrong ANSWER rather than to an error, and
1216
+ * that is the one class this package refuses to leave to a single guard
1217
+ * (Prime Directive #12 — refuse at the door, do not tolerate at the
1218
+ * consumer). One line, no measurable cost, and it turns a routing regression
1219
+ * into a loud refusal instead of an empty chart.
1220
+ *
1221
+ * Deliberately BARE — an undeclared 500, not `INVALID_FILTER` / 400 — for the
1222
+ * reason `buildFilterClauseSql`'s #5333 exit in `objectql-strategy.ts` gives
1223
+ * for the same class: the caller's filter is legal and is served on the
1224
+ * engine path, so an arrival here is drift between our own routing gate and
1225
+ * our own emitter. Billing the caller 400 for that would hide a platform bug
1226
+ * from 5xx alerting and tell a dashboard user to fix a filter that is fine.
1227
+ * Same tier as `resolveMeasureSql`'s unrecognised-`Metric.type` throw below.
1228
+ */
1229
+ private assertNoCrossFieldComparison;
1080
1230
  execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult>;
1081
1231
  generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{
1082
1232
  sql: string;
@@ -1464,9 +1614,29 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1464
1614
  * string — `String(…)`, the same normalisation `like-pattern.ts` applies at the
1465
1615
  * two SQL emitters and `driver-sql`'s `applyLike` applies at the driver, so one
1466
1616
  * `$contains` means one thing on every face (#5567's invariant).
1617
+ *
1618
+ * [#5234] Those four `String(…)` calls now only ever see a value that renders
1619
+ * faithfully: `fieldLeaves` refuses an object comparand on this family before a
1620
+ * leaf exists. That ordering is load-bearing rather than incidental — this arm
1621
+ * is a PRODUCER for the engine, so stringifying an object here would have
1622
+ * laundered it into `'[object Object]'` and handed a driver a perfectly
1623
+ * well-typed string. A strict driver downstream could never have seen the shape
1624
+ * it was strict about, which is why the guard sits at the door and not here.
1467
1625
  */
1468
1626
  private convertFilter;
1469
1627
  private extractObjectName;
1628
+ /**
1629
+ * [#7598] The query's `where`, lowered — the same input
1630
+ * `NativeSQLStrategy.canHandle` scans, so the strategy that DECLINED and the
1631
+ * echo that refuses read one shape rather than two.
1632
+ *
1633
+ * A throw from the lowering is swallowed for the same reason it is there: the
1634
+ * `where` is malformed either way and `normalizeAnalyticsFilterTree` below
1635
+ * refuses it with the message and envelope it has always had. This helper's
1636
+ * only job is finding a reference, and there is none to find in a filter that
1637
+ * does not lower.
1638
+ */
1639
+ private loweredWhere;
1470
1640
  /**
1471
1641
  * The dimensions this query PROJECTS, in the order the result carries them:
1472
1642
  * every `dimensions` entry, then every granular `timeDimensions` entry that
package/dist/index.d.ts CHANGED
@@ -105,23 +105,32 @@ type RelationshipResolver = (baseObject: string, relationshipName: string) => st
105
105
  */
106
106
  interface DatasetCompileOptions {
107
107
  /**
108
- * [#5115] The datasource `objectName` DECLARES (`object.datasource`), or
109
- * `undefined` when nothing authoritative can answer (no data engine, unknown
110
- * object).
108
+ * [#5115] The datasource `objectName` is BOUND to, or `undefined` when nothing
109
+ * authoritative can answer (no data engine, unknown object) — or when nothing
110
+ * binds the object at all and it rides the deployment's default datasource.
111
111
  *
112
112
  * With it the compiler can settle at COMPILE time what #5033 could only
113
113
  * report at QUERY time: a dataset whose join crosses datasources declares a
114
114
  * statement no driver can execute, because the analytics engine lowers the
115
115
  * whole dataset into ONE SQL statement on the base object's datasource.
116
116
  *
117
- * IMPORTANT `'default'` is not an answer. In `ObjectQL.getDriver`'s
118
- * resolution order an explicit `object.datasource` other than `'default'`
119
- * wins outright (step 1); `'default'` is the schema's DEFAULT value and means
120
- * only "no explicit binding", after which routing is decided by
121
- * `datasourceMapping` rules, the ADR-0057 §3.6 lifecycle split, and the
122
- * owning package's `defaultDatasource` none of which are visible from here.
123
- * The compiler therefore treats `'default'`/`undefined` as UNANSWERED. See
124
- * {@link compileDataset}.
117
+ * [#5288] What the host supplies here changed shape, the rule below did not.
118
+ * It used to be the object's DECLARED `datasource` step 1 of the five
119
+ * `ObjectQL.getDriver` routes by so an object placed by a
120
+ * `datasourceMapping` rule, by the ADR-0057 §3.6 lifecycle split, or by its
121
+ * package's `defaultDatasource` answered `'default'` and was read here as
122
+ * unanswered. The built-in host (`plugin.ts`) now asks the engine's own
123
+ * resolver instead, so those three placements ARE visible from here and a join
124
+ * between two objects bound to two different datasources is decidable
125
+ * whichever mechanism bound them.
126
+ *
127
+ * Still deliberately UNANSWERED: `'default'`, and the object that no rule
128
+ * places anywhere. The deployment's default driver keeps its natural name
129
+ * (#3826), so "rides the default" is reported as `undefined` rather than as a
130
+ * name — which means a join from a bound object to a default-riding one stays
131
+ * undecidable here and remains the query-time diagnostic's business (#5288
132
+ * records this boundary; widening it is #5115's follow-up, not this rule's).
133
+ * See {@link compileDataset}.
125
134
  */
126
135
  getObjectDatasource?: (objectName: string) => string | undefined;
127
136
  /**
@@ -407,6 +416,15 @@ interface AnalyticsServiceConfig {
407
416
  * datasources before any query is ever built. Absence keeps the pre-#5115
408
417
  * behaviour exactly ("cannot answer, do not block") — the query-time
409
418
  * diagnostic above stays as the backstop.
419
+ *
420
+ * [#5288] "Bound to" above is the whole contract, and it took until #5288 for
421
+ * the built-in host to honour it: `plugin.ts` answered with the object's
422
+ * DECLARED `datasource` — step 1 of the five `ObjectQL.getDriver` routes by —
423
+ * so an object placed by a `datasourceMapping` rule, by the ADR-0057 §3.6
424
+ * lifecycle split, or by its package's `defaultDatasource` reported
425
+ * `'default'` and sent the message above to the wrong database. It now asks
426
+ * `ObjectQL.resolveEffectiveDatasource`. A custom host owes the same answer:
427
+ * the datasource an object is BOUND to, `undefined` when nothing binds it.
410
428
  */
411
429
  getObjectDatasource?: (objectName: string) => string | undefined;
412
430
  /**
@@ -504,6 +522,42 @@ interface AnalyticsServiceConfig {
504
522
  * across the publish boundary. Reads only; never touches physical tables.
505
523
  */
506
524
  draftRowsResolver?: (objectName: string, context?: ExecutionContext) => Promise<Record<string, unknown>[] | null>;
525
+ /**
526
+ * [#8286] Echo the executed statement back to the CALLER in
527
+ * `AnalyticsResult.sql`. **Off unless this host opts in.**
528
+ *
529
+ * The contract has always declared the echo debug-only —
530
+ * `AnalyticsResultResponseSchema.data.sql` (`spec/api/analytics.zod.ts`) is
531
+ * `optional()` and describes itself as "Executed SQL (if debug enabled)" —
532
+ * but no implementation ever read a debug switch, so every `/analytics/query`
533
+ * response carried the statement, on production deployments included. This
534
+ * field is that switch: declared = enforced, restored at the one seam the
535
+ * response leaves through ({@link AnalyticsService.query}).
536
+ *
537
+ * **Default** — `NODE_ENV === 'development'`, and nothing else. In
538
+ * particular an UNSET `NODE_ENV` counts as production and the echo stays
539
+ * off: that is the maintainer's 2026-08-06 ruling for machine-readable
540
+ * environment answers (see `resolveDiscoveryEnvironment` and the note at
541
+ * `runtime/src/http-dispatcher.ts`), and of the two ways to be wrong,
542
+ * disclosing on a production deployment whose operator forgot the variable
543
+ * is the dangerous one. `os start` forces `NODE_ENV='production'` when
544
+ * unset, `os serve` resolves `NODE_ENV || 'production'`, `os doctor` derives
545
+ * the same expression — this switch now reads the absence the same way.
546
+ *
547
+ * **Why not a request field.** There is none, deliberately: a caller-set
548
+ * debug flag would let any tenant re-open the disclosure on demand, which is
549
+ * the shape of the defect rather than a fix for it. The echo is a HOST
550
+ * decision, and the caller-facing surface for "show me the SQL" already
551
+ * exists as the dedicated dry-run route `/api/v1/analytics/sql`
552
+ * (`generateSql`), which this switch does not touch.
553
+ *
554
+ * **Why not the plugin's `debug` (log) option.** Server-side log verbosity
555
+ * and what travels to a caller are different decisions with different blast
556
+ * radii; folding them together means a support engineer raising log level on
557
+ * a live deployment silently reopens the disclosure. Two switches, named for
558
+ * what they open.
559
+ */
560
+ debugSql?: boolean;
507
561
  }
508
562
  /**
509
563
  * AnalyticsService — Multi-driver analytics orchestrator.
@@ -551,6 +605,11 @@ declare class AnalyticsService implements IAnalyticsService {
551
605
  private readonly isExternalObject?;
552
606
  /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
553
607
  private warnedNoObjectRegistry;
608
+ /**
609
+ * [#8286] Does the executed statement travel back to the caller?
610
+ * See {@link AnalyticsServiceConfig.debugSql} for the switch and its default.
611
+ */
612
+ private readonly debugSql;
554
613
  readonly cubeRegistry: CubeRegistry;
555
614
  private readonly logger;
556
615
  constructor(config?: AnalyticsServiceConfig);
@@ -586,6 +645,25 @@ declare class AnalyticsService implements IAnalyticsService {
586
645
  * Any other error propagates untouched.
587
646
  */
588
647
  query(query: AnalyticsQuery, context?: ExecutionContext): Promise<AnalyticsResult>;
648
+ /**
649
+ * [#8286] Withhold the executed statement unless this host enabled the echo.
650
+ *
651
+ * Applied at {@link query}, which is the response-assembly seam for BOTH
652
+ * faces that serve callers: `/api/v1/analytics/query` calls it directly, and
653
+ * `queryDataset` reaches it through `DatasetExecutor`, so a dataset response
654
+ * inherits the same verdict without a second gate to keep in step.
655
+ * `generateSql` — the dedicated `/api/v1/analytics/sql` dry-run route — is
656
+ * deliberately NOT gated: asking for the statement is that route's entire
657
+ * purpose, and it is the surface a debugging author is meant to use.
658
+ *
659
+ * What the echo disclosed, and why "it is only a table name" understates it:
660
+ * the statement carries the compiled read scope, i.e. the SHAPE of the
661
+ * isolation predicate (`"sys_user"."id" IN ($2, $3, …)` rather than an
662
+ * `organization_id` comparison) plus its bound-parameter arity, which counts
663
+ * the caller's own org membership. No wall was breached by it — the echo is
664
+ * information disclosure, and this is the disclosure closing.
665
+ */
666
+ private applySqlEchoPolicy;
589
667
  /**
590
668
  * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
591
669
  * can be queried by name. Idempotent (re-registering overwrites). Returns the
@@ -665,6 +743,13 @@ declare class AnalyticsService implements IAnalyticsService {
665
743
  * the data path's `resolveQueryFields`: they are engine-assigned rather than
666
744
  * declared, and a gate stricter than the engine it guards would reject
667
745
  * queries that used to work.
746
+ *
747
+ * [#5918] Its `stripPrefix` below is deliberately NOT narrowed the way the two
748
+ * MINTS were. This is a RESOLVER — it mirrors `lookupMember`'s tiers to answer
749
+ * "which Metric will the strategy read", and that tier order did not change.
750
+ * What changed is what can reach it: a dotted measure is now either a
751
+ * `<cube>.` qualifier or a key the cube itself declares, because every other
752
+ * dotted spelling is refused at the mint before this gate runs.
668
753
  */
669
754
  private assertMeasureFields;
670
755
  /**
@@ -868,6 +953,17 @@ interface AnalyticsServicePluginOptions {
868
953
  getAllowedRelationships?: (cubeName: string) => Set<string> | undefined;
869
954
  /** Enable debug logging. */
870
955
  debug?: boolean;
956
+ /**
957
+ * [#8286] Echo the executed statement back to CALLERS in
958
+ * `AnalyticsResult.sql` (`/api/v1/analytics/query`).
959
+ *
960
+ * Distinct from {@link AnalyticsServicePluginOptions.debug} above, which is
961
+ * server-side log verbosity only: raising log level must never widen what
962
+ * travels to a tenant. Default and rationale live with the service config —
963
+ * see `AnalyticsServiceConfig.debugSql`. Undefined here means "no host
964
+ * choice", which the service resolves to development-only.
965
+ */
966
+ debugSql?: boolean;
871
967
  }
872
968
  /**
873
969
  * AnalyticsServicePlugin — Kernel plugin for multi-driver analytics.
@@ -1041,6 +1137,19 @@ declare class DatasetExecutor {
1041
1137
  * ISO strings chronologically, and bucket keys are minted sort-stable.)
1042
1138
  */
1043
1139
  private timeDimensionsOf;
1140
+ /**
1141
+ * The EFFECTIVE bucket size one dimension is grouped at for this selection,
1142
+ * or `undefined` when it is not a date dimension or nothing states a size (in
1143
+ * which case the runtime groups the raw column).
1144
+ *
1145
+ * One definition, two readers, deliberately: {@link buildQuery} uses it to
1146
+ * decide the `GROUP BY`, and {@link runCompare} uses it to realign the
1147
+ * comparison pass's bucket keys (#6007). Those two MUST agree — realigning
1148
+ * `month` keys a query grouped by `quarter` would move every comparison value
1149
+ * onto a bucket that does not exist — and the way to make them agree is to
1150
+ * have one of them, not two that look alike.
1151
+ */
1152
+ private granularityOf;
1044
1153
  private buildQuery;
1045
1154
  private runCompare;
1046
1155
  }
@@ -1077,6 +1186,47 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
1077
1186
  readonly name = "NativeSQLStrategy";
1078
1187
  readonly priority = 10;
1079
1188
  canHandle(query: AnalyticsQuery, ctx: StrategyContext): boolean;
1189
+ /**
1190
+ * [#7598] Does serving this query require the cross-field capability this
1191
+ * strategy declines? See the ruling recorded at {@link canHandle}.
1192
+ *
1193
+ * ⚠️ This and {@link assertNoCrossFieldComparison} read the SAME inputs
1194
+ * through the SAME detector, which is what makes the decline and the
1195
+ * fail-closed backstop unable to drift: a shape one of them recognises is a
1196
+ * shape the other recognises.
1197
+ */
1198
+ private carriesCrossFieldComparison;
1199
+ private crossFieldComparisonIn;
1200
+ /**
1201
+ * [#7598] The fail-closed backstop at the door that BINDS.
1202
+ *
1203
+ * ⚠️ **Unreachable by construction, and kept deliberately** — saying so
1204
+ * because #7598's brief asks that a refusal arm which has become unreachable
1205
+ * be named rather than left to be re-discovered. {@link canHandle} declines
1206
+ * every query this would fire on, and it declines using
1207
+ * {@link crossFieldComparisonIn} — the same walk over the same two inputs —
1208
+ * so `resolveStrategy` cannot hand this strategy a query carrying one.
1209
+ *
1210
+ * It is kept because of what the failure mode is if that ever stops being
1211
+ * true. The defect #7598 measured was not a missing error: it was a SILENT
1212
+ * BIND — `toSqlBindValue` JSON-stringifies the reference object, so the
1213
+ * statement compiled perfectly and compared a column against the text
1214
+ * `{"$field":"budget"}`, a value no row can hold. A routing gate that misses
1215
+ * a shape therefore degrades to a wrong ANSWER rather than to an error, and
1216
+ * that is the one class this package refuses to leave to a single guard
1217
+ * (Prime Directive #12 — refuse at the door, do not tolerate at the
1218
+ * consumer). One line, no measurable cost, and it turns a routing regression
1219
+ * into a loud refusal instead of an empty chart.
1220
+ *
1221
+ * Deliberately BARE — an undeclared 500, not `INVALID_FILTER` / 400 — for the
1222
+ * reason `buildFilterClauseSql`'s #5333 exit in `objectql-strategy.ts` gives
1223
+ * for the same class: the caller's filter is legal and is served on the
1224
+ * engine path, so an arrival here is drift between our own routing gate and
1225
+ * our own emitter. Billing the caller 400 for that would hide a platform bug
1226
+ * from 5xx alerting and tell a dashboard user to fix a filter that is fine.
1227
+ * Same tier as `resolveMeasureSql`'s unrecognised-`Metric.type` throw below.
1228
+ */
1229
+ private assertNoCrossFieldComparison;
1080
1230
  execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult>;
1081
1231
  generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{
1082
1232
  sql: string;
@@ -1464,9 +1614,29 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1464
1614
  * string — `String(…)`, the same normalisation `like-pattern.ts` applies at the
1465
1615
  * two SQL emitters and `driver-sql`'s `applyLike` applies at the driver, so one
1466
1616
  * `$contains` means one thing on every face (#5567's invariant).
1617
+ *
1618
+ * [#5234] Those four `String(…)` calls now only ever see a value that renders
1619
+ * faithfully: `fieldLeaves` refuses an object comparand on this family before a
1620
+ * leaf exists. That ordering is load-bearing rather than incidental — this arm
1621
+ * is a PRODUCER for the engine, so stringifying an object here would have
1622
+ * laundered it into `'[object Object]'` and handed a driver a perfectly
1623
+ * well-typed string. A strict driver downstream could never have seen the shape
1624
+ * it was strict about, which is why the guard sits at the door and not here.
1467
1625
  */
1468
1626
  private convertFilter;
1469
1627
  private extractObjectName;
1628
+ /**
1629
+ * [#7598] The query's `where`, lowered — the same input
1630
+ * `NativeSQLStrategy.canHandle` scans, so the strategy that DECLINED and the
1631
+ * echo that refuses read one shape rather than two.
1632
+ *
1633
+ * A throw from the lowering is swallowed for the same reason it is there: the
1634
+ * `where` is malformed either way and `normalizeAnalyticsFilterTree` below
1635
+ * refuses it with the message and envelope it has always had. This helper's
1636
+ * only job is finding a reference, and there is none to find in a filter that
1637
+ * does not lower.
1638
+ */
1639
+ private loweredWhere;
1470
1640
  /**
1471
1641
  * The dimensions this query PROJECTS, in the order the result carries them:
1472
1642
  * every `dimensions` entry, then every granular `timeDimensions` entry that