@objectstack/service-analytics 17.0.0-rc.6 → 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
@@ -522,6 +522,42 @@ interface AnalyticsServiceConfig {
522
522
  * across the publish boundary. Reads only; never touches physical tables.
523
523
  */
524
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;
525
561
  }
526
562
  /**
527
563
  * AnalyticsService — Multi-driver analytics orchestrator.
@@ -569,6 +605,11 @@ declare class AnalyticsService implements IAnalyticsService {
569
605
  private readonly isExternalObject?;
570
606
  /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
571
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;
572
613
  readonly cubeRegistry: CubeRegistry;
573
614
  private readonly logger;
574
615
  constructor(config?: AnalyticsServiceConfig);
@@ -604,6 +645,25 @@ declare class AnalyticsService implements IAnalyticsService {
604
645
  * Any other error propagates untouched.
605
646
  */
606
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;
607
667
  /**
608
668
  * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
609
669
  * can be queried by name. Idempotent (re-registering overwrites). Returns the
@@ -893,6 +953,17 @@ interface AnalyticsServicePluginOptions {
893
953
  getAllowedRelationships?: (cubeName: string) => Set<string> | undefined;
894
954
  /** Enable debug logging. */
895
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;
896
967
  }
897
968
  /**
898
969
  * AnalyticsServicePlugin — Kernel plugin for multi-driver analytics.
@@ -1115,6 +1186,47 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
1115
1186
  readonly name = "NativeSQLStrategy";
1116
1187
  readonly priority = 10;
1117
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;
1118
1230
  execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult>;
1119
1231
  generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{
1120
1232
  sql: string;
@@ -1513,6 +1625,18 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1513
1625
  */
1514
1626
  private convertFilter;
1515
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;
1516
1640
  /**
1517
1641
  * The dimensions this query PROJECTS, in the order the result carries them:
1518
1642
  * every `dimensions` entry, then every granular `timeDimensions` entry that
package/dist/index.d.ts CHANGED
@@ -522,6 +522,42 @@ interface AnalyticsServiceConfig {
522
522
  * across the publish boundary. Reads only; never touches physical tables.
523
523
  */
524
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;
525
561
  }
526
562
  /**
527
563
  * AnalyticsService — Multi-driver analytics orchestrator.
@@ -569,6 +605,11 @@ declare class AnalyticsService implements IAnalyticsService {
569
605
  private readonly isExternalObject?;
570
606
  /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
571
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;
572
613
  readonly cubeRegistry: CubeRegistry;
573
614
  private readonly logger;
574
615
  constructor(config?: AnalyticsServiceConfig);
@@ -604,6 +645,25 @@ declare class AnalyticsService implements IAnalyticsService {
604
645
  * Any other error propagates untouched.
605
646
  */
606
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;
607
667
  /**
608
668
  * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
609
669
  * can be queried by name. Idempotent (re-registering overwrites). Returns the
@@ -893,6 +953,17 @@ interface AnalyticsServicePluginOptions {
893
953
  getAllowedRelationships?: (cubeName: string) => Set<string> | undefined;
894
954
  /** Enable debug logging. */
895
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;
896
967
  }
897
968
  /**
898
969
  * AnalyticsServicePlugin — Kernel plugin for multi-driver analytics.
@@ -1115,6 +1186,47 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
1115
1186
  readonly name = "NativeSQLStrategy";
1116
1187
  readonly priority = 10;
1117
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;
1118
1230
  execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult>;
1119
1231
  generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{
1120
1232
  sql: string;
@@ -1513,6 +1625,18 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1513
1625
  */
1514
1626
  private convertFilter;
1515
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;
1516
1640
  /**
1517
1641
  * The dimensions this query PROJECTS, in the order the result carries them:
1518
1642
  * every `dimensions` entry, then every granular `timeDimensions` entry that