@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/CHANGELOG.md +5376 -0
- package/dist/index.cjs +609 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +181 -11
- package/dist/index.d.ts +181 -11
- package/dist/index.js +606 -35
- package/dist/index.js.map +1 -1
- package/package.json +7 -4
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`
|
|
109
|
-
*
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* `
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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`
|
|
109
|
-
*
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* `
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|