@objectstack/service-analytics 17.0.0-rc.6 → 17.1.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,74 @@ 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
+ * [#8690] Does the query's `where` compare a declared TIME dimension against
1191
+ * a value no temporal storage rule can read? See the ruling at
1192
+ * {@link canHandle}.
1193
+ *
1194
+ * The classification comes from the CUBE, the only metadata this package has:
1195
+ * a dimension declares `type: 'time'` (compiled from the dataset dimension's
1196
+ * `type: 'date'`), and {@link lookupMember} is the same resolution every other
1197
+ * member lookup in this strategy uses, so "the member the gate classified"
1198
+ * and "the member the compiler emits" cannot drift apart.
1199
+ *
1200
+ * A `time` dimension is read with the DATETIME rule — the permissive one of
1201
+ * the three. That is the right direction because this is a routing decision,
1202
+ * not a verdict: the engine door re-judges with the field's real declared
1203
+ * type and has the final say, so under-classifying an exotic spelling merely
1204
+ * leaves today's behaviour, while over-classifying would silently move a
1205
+ * working dashboard off the fast path. The comparands this card measured
1206
+ * (`last_30_days`, `not-a-date-at-all`) are unreadable under all three rules,
1207
+ * so the decline fires for them whichever backing type the dimension has.
1208
+ *
1209
+ * `lowerAnalyticsWhere` rather than `query.where` raw, so the authored ARRAY
1210
+ * sugar is seen after `parseFilterAST` has lowered it; a THROW from that
1211
+ * lowering is not this gate's to answer — the filter is malformed either way
1212
+ * and `normalizeAnalyticsFilterTree` refuses it a moment later with the
1213
+ * message and envelope it has always had.
1214
+ */
1215
+ private carriesUninterpretableTemporalComparand;
1216
+ /**
1217
+ * [#7598] Does serving this query require the cross-field capability this
1218
+ * strategy declines? See the ruling recorded at {@link canHandle}.
1219
+ *
1220
+ * ⚠️ This and {@link assertNoCrossFieldComparison} read the SAME inputs
1221
+ * through the SAME detector, which is what makes the decline and the
1222
+ * fail-closed backstop unable to drift: a shape one of them recognises is a
1223
+ * shape the other recognises.
1224
+ */
1225
+ private carriesCrossFieldComparison;
1226
+ private crossFieldComparisonIn;
1227
+ /**
1228
+ * [#7598] The fail-closed backstop at the door that BINDS.
1229
+ *
1230
+ * ⚠️ **Unreachable by construction, and kept deliberately** — saying so
1231
+ * because #7598's brief asks that a refusal arm which has become unreachable
1232
+ * be named rather than left to be re-discovered. {@link canHandle} declines
1233
+ * every query this would fire on, and it declines using
1234
+ * {@link crossFieldComparisonIn} — the same walk over the same two inputs —
1235
+ * so `resolveStrategy` cannot hand this strategy a query carrying one.
1236
+ *
1237
+ * It is kept because of what the failure mode is if that ever stops being
1238
+ * true. The defect #7598 measured was not a missing error: it was a SILENT
1239
+ * BIND — `toSqlBindValue` JSON-stringifies the reference object, so the
1240
+ * statement compiled perfectly and compared a column against the text
1241
+ * `{"$field":"budget"}`, a value no row can hold. A routing gate that misses
1242
+ * a shape therefore degrades to a wrong ANSWER rather than to an error, and
1243
+ * that is the one class this package refuses to leave to a single guard
1244
+ * (Prime Directive #12 — refuse at the door, do not tolerate at the
1245
+ * consumer). One line, no measurable cost, and it turns a routing regression
1246
+ * into a loud refusal instead of an empty chart.
1247
+ *
1248
+ * Deliberately BARE — an undeclared 500, not `INVALID_FILTER` / 400 — for the
1249
+ * reason `buildFilterClauseSql`'s #5333 exit in `objectql-strategy.ts` gives
1250
+ * for the same class: the caller's filter is legal and is served on the
1251
+ * engine path, so an arrival here is drift between our own routing gate and
1252
+ * our own emitter. Billing the caller 400 for that would hide a platform bug
1253
+ * from 5xx alerting and tell a dashboard user to fix a filter that is fine.
1254
+ * Same tier as `resolveMeasureSql`'s unrecognised-`Metric.type` throw below.
1255
+ */
1256
+ private assertNoCrossFieldComparison;
1118
1257
  execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult>;
1119
1258
  generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{
1120
1259
  sql: string;
@@ -1513,6 +1652,18 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1513
1652
  */
1514
1653
  private convertFilter;
1515
1654
  private extractObjectName;
1655
+ /**
1656
+ * [#7598] The query's `where`, lowered — the same input
1657
+ * `NativeSQLStrategy.canHandle` scans, so the strategy that DECLINED and the
1658
+ * echo that refuses read one shape rather than two.
1659
+ *
1660
+ * A throw from the lowering is swallowed for the same reason it is there: the
1661
+ * `where` is malformed either way and `normalizeAnalyticsFilterTree` below
1662
+ * refuses it with the message and envelope it has always had. This helper's
1663
+ * only job is finding a reference, and there is none to find in a filter that
1664
+ * does not lower.
1665
+ */
1666
+ private loweredWhere;
1516
1667
  /**
1517
1668
  * The dimensions this query PROJECTS, in the order the result carries them:
1518
1669
  * 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,74 @@ 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
+ * [#8690] Does the query's `where` compare a declared TIME dimension against
1191
+ * a value no temporal storage rule can read? See the ruling at
1192
+ * {@link canHandle}.
1193
+ *
1194
+ * The classification comes from the CUBE, the only metadata this package has:
1195
+ * a dimension declares `type: 'time'` (compiled from the dataset dimension's
1196
+ * `type: 'date'`), and {@link lookupMember} is the same resolution every other
1197
+ * member lookup in this strategy uses, so "the member the gate classified"
1198
+ * and "the member the compiler emits" cannot drift apart.
1199
+ *
1200
+ * A `time` dimension is read with the DATETIME rule — the permissive one of
1201
+ * the three. That is the right direction because this is a routing decision,
1202
+ * not a verdict: the engine door re-judges with the field's real declared
1203
+ * type and has the final say, so under-classifying an exotic spelling merely
1204
+ * leaves today's behaviour, while over-classifying would silently move a
1205
+ * working dashboard off the fast path. The comparands this card measured
1206
+ * (`last_30_days`, `not-a-date-at-all`) are unreadable under all three rules,
1207
+ * so the decline fires for them whichever backing type the dimension has.
1208
+ *
1209
+ * `lowerAnalyticsWhere` rather than `query.where` raw, so the authored ARRAY
1210
+ * sugar is seen after `parseFilterAST` has lowered it; a THROW from that
1211
+ * lowering is not this gate's to answer — the filter is malformed either way
1212
+ * and `normalizeAnalyticsFilterTree` refuses it a moment later with the
1213
+ * message and envelope it has always had.
1214
+ */
1215
+ private carriesUninterpretableTemporalComparand;
1216
+ /**
1217
+ * [#7598] Does serving this query require the cross-field capability this
1218
+ * strategy declines? See the ruling recorded at {@link canHandle}.
1219
+ *
1220
+ * ⚠️ This and {@link assertNoCrossFieldComparison} read the SAME inputs
1221
+ * through the SAME detector, which is what makes the decline and the
1222
+ * fail-closed backstop unable to drift: a shape one of them recognises is a
1223
+ * shape the other recognises.
1224
+ */
1225
+ private carriesCrossFieldComparison;
1226
+ private crossFieldComparisonIn;
1227
+ /**
1228
+ * [#7598] The fail-closed backstop at the door that BINDS.
1229
+ *
1230
+ * ⚠️ **Unreachable by construction, and kept deliberately** — saying so
1231
+ * because #7598's brief asks that a refusal arm which has become unreachable
1232
+ * be named rather than left to be re-discovered. {@link canHandle} declines
1233
+ * every query this would fire on, and it declines using
1234
+ * {@link crossFieldComparisonIn} — the same walk over the same two inputs —
1235
+ * so `resolveStrategy` cannot hand this strategy a query carrying one.
1236
+ *
1237
+ * It is kept because of what the failure mode is if that ever stops being
1238
+ * true. The defect #7598 measured was not a missing error: it was a SILENT
1239
+ * BIND — `toSqlBindValue` JSON-stringifies the reference object, so the
1240
+ * statement compiled perfectly and compared a column against the text
1241
+ * `{"$field":"budget"}`, a value no row can hold. A routing gate that misses
1242
+ * a shape therefore degrades to a wrong ANSWER rather than to an error, and
1243
+ * that is the one class this package refuses to leave to a single guard
1244
+ * (Prime Directive #12 — refuse at the door, do not tolerate at the
1245
+ * consumer). One line, no measurable cost, and it turns a routing regression
1246
+ * into a loud refusal instead of an empty chart.
1247
+ *
1248
+ * Deliberately BARE — an undeclared 500, not `INVALID_FILTER` / 400 — for the
1249
+ * reason `buildFilterClauseSql`'s #5333 exit in `objectql-strategy.ts` gives
1250
+ * for the same class: the caller's filter is legal and is served on the
1251
+ * engine path, so an arrival here is drift between our own routing gate and
1252
+ * our own emitter. Billing the caller 400 for that would hide a platform bug
1253
+ * from 5xx alerting and tell a dashboard user to fix a filter that is fine.
1254
+ * Same tier as `resolveMeasureSql`'s unrecognised-`Metric.type` throw below.
1255
+ */
1256
+ private assertNoCrossFieldComparison;
1118
1257
  execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult>;
1119
1258
  generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{
1120
1259
  sql: string;
@@ -1513,6 +1652,18 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1513
1652
  */
1514
1653
  private convertFilter;
1515
1654
  private extractObjectName;
1655
+ /**
1656
+ * [#7598] The query's `where`, lowered — the same input
1657
+ * `NativeSQLStrategy.canHandle` scans, so the strategy that DECLINED and the
1658
+ * echo that refuses read one shape rather than two.
1659
+ *
1660
+ * A throw from the lowering is swallowed for the same reason it is there: the
1661
+ * `where` is malformed either way and `normalizeAnalyticsFilterTree` below
1662
+ * refuses it with the message and envelope it has always had. This helper's
1663
+ * only job is finding a reference, and there is none to find in a filter that
1664
+ * does not lower.
1665
+ */
1666
+ private loweredWhere;
1516
1667
  /**
1517
1668
  * The dimensions this query PROJECTS, in the order the result carries them:
1518
1669
  * every `dimensions` entry, then every granular `timeDimensions` entry that