@speedkit/cli 4.27.1 → 4.27.2

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +29 -14
  3. package/dist/commands/auto-prewarm.d.ts +1 -0
  4. package/dist/commands/auto-prewarm.js +2 -1
  5. package/dist/commands/query/parameter.d.ts +1 -0
  6. package/dist/commands/query/prewarm.d.ts +1 -0
  7. package/dist/helpers/clipboard.d.ts +6 -0
  8. package/dist/helpers/clipboard.js +7 -1
  9. package/dist/models/cli-parameters.d.ts +6 -0
  10. package/dist/models/cli-parameters.js +12 -0
  11. package/dist/services/athena/athena-service-factory.d.ts +5 -1
  12. package/dist/services/athena/athena-service-factory.js +8 -3
  13. package/dist/services/athena/athena-service-model.d.ts +17 -0
  14. package/dist/services/athena/athena-service-model.js +19 -0
  15. package/dist/services/athena/athena-service.d.ts +11 -1
  16. package/dist/services/athena/athena-service.js +10 -3
  17. package/dist/services/athena/athena-service.spec.js +16 -0
  18. package/dist/services/athena/baqend-athena-service.d.ts +13 -4
  19. package/dist/services/athena/baqend-athena-service.js +25 -10
  20. package/dist/services/athena/baqend-athena-service.spec.d.ts +1 -0
  21. package/dist/services/athena/baqend-athena-service.spec.js +51 -0
  22. package/dist/services/cli/cli-service.d.ts +9 -0
  23. package/dist/services/cli/cli-service.js +57 -3
  24. package/dist/services/cli/cli-service.spec.d.ts +1 -0
  25. package/dist/services/cli/cli-service.spec.js +67 -0
  26. package/dist/services/prewarm/auto-pre-warm-factory.d.ts +2 -0
  27. package/dist/services/prewarm/auto-pre-warm-factory.js +1 -0
  28. package/dist/services/query-builder/queries/page-source.d.ts +11 -2
  29. package/dist/services/query-builder/queries/page-source.js +15 -3
  30. package/dist/services/query-builder/queries/query-model.d.ts +2 -0
  31. package/dist/services/query-builder/queries/spec/queries.spec.js +17 -0
  32. package/dist/services/query-builder/query-builder-factory.js +2 -1
  33. package/dist/services/query-builder/query-builder-model.d.ts +4 -0
  34. package/dist/services/query-builder/query-builder-service.js +12 -7
  35. package/dist/services/query-builder/query-builder-service.spec.d.ts +1 -0
  36. package/dist/services/query-builder/query-builder-service.spec.js +65 -0
  37. package/dist/services/query-builder/query-command.d.ts +1 -0
  38. package/dist/services/query-builder/query-command.js +1 -0
  39. package/oclif.manifest.json +31 -1
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [4.27.2](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.27.1...v4.27.2) (2026-09-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * query define awsCatalog via --catalog, disable spinners while CI or not a tty ([e949dbb](https://gitlab.orestes.info/baqend/speed-kit-cli/commit/e949dbbaf73521267e03c4c043b393e76e174ece))
7
+
1
8
  ## [4.27.1](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.27.0...v4.27.1) (2026-09-11)
2
9
 
3
10
 
package/README.md CHANGED
@@ -21,7 +21,7 @@ $ npm install -g @speedkit/cli
21
21
  $ sk COMMAND
22
22
  running command...
23
23
  $ sk (--version)
24
- @speedkit/cli/4.27.1 linux-x64 node-v22.23.2
24
+ @speedkit/cli/4.27.2 linux-x64 node-v22.23.2
25
25
  $ sk --help [COMMAND]
26
26
  USAGE
27
27
  $ sk COMMAND
@@ -103,9 +103,10 @@ Build and execute the prewarm query for a customer config and start a prewarm jo
103
103
 
104
104
  ```
105
105
  USAGE
106
- $ sk auto-prewarm CUSTOMERPATH [--workgroup <value>] [-c <value>] [-d <value>] [--excludeNoindex]
107
- [--excludeSuspicious] [--explain] [--hardNavigationsOnly] [--excludeNotFound] [--limit <value>] [--match <value>...]
108
- [--minHits <value>] [-p <value> | [--device desktop|mobile|tablet... | -v <value>...] | ] [-k] [-q] [--verbose]
106
+ $ sk auto-prewarm CUSTOMERPATH [--catalog <value>] [--workgroup <value>] [-c <value>] [-d <value>]
107
+ [--excludeNoindex] [--excludeSuspicious] [--explain] [--hardNavigationsOnly] [--excludeNotFound] [--limit <value>]
108
+ [--match <value>...] [--minHits <value>] [-p <value> | [--device desktop|mobile|tablet... | -v <value>...] | ] [-k]
109
+ [-q] [--verbose]
109
110
 
110
111
  ARGUMENTS
111
112
  CUSTOMERPATH The customer config path
@@ -118,6 +119,8 @@ FLAGS
118
119
  -q, --quiet less output non interactive
119
120
  -v, --variation=<value>... Pair every url of the list with each of these variations. For the custom variations no
120
121
  traffic can reveal, e.g. 'desktop-pdp-a,mobile-pdp-a'
122
+ --catalog=<value> [default: live, env: SK_ATHENA_CATALOG] Athena data catalog holding the rum tables. Names
123
+ the catalog both in the query and in the context it runs in, so the two always agree
121
124
  --device=<option>... Split the list by device class, so every url is warmed in the variations it was actually
122
125
  requested in. A class not named is left out
123
126
  <options: desktop|mobile|tablet>
@@ -261,8 +264,9 @@ Build a parameter query that can be executed in Athena
261
264
 
262
265
  ```
263
266
  USAGE
264
- $ sk build-parameter-query CUSTOMERPATH [--workgroup <value>] [-c <value>] [-d <value>] [--excludeNoindex]
265
- [--excludeSuspicious] [--explain] [--hardNavigationsOnly] [-e] [-q] [--bySet] [--minImpressions <value>]
267
+ $ sk build-parameter-query CUSTOMERPATH [--catalog <value>] [--workgroup <value>] [-c <value>] [-d <value>]
268
+ [--excludeNoindex] [--excludeSuspicious] [--explain] [--hardNavigationsOnly] [-e] [-q] [--bySet] [--minImpressions
269
+ <value>]
266
270
 
267
271
  ARGUMENTS
268
272
  CUSTOMERPATH The customer config path
@@ -274,6 +278,9 @@ FLAGS
274
278
  -q, --quiet Output only the raw query without clipboard copy or styling
275
279
  --bySet Rank whole sets of parameters instead of single parameters, to see which ones travel
276
280
  together
281
+ --catalog=<value> [default: live, env: SK_ATHENA_CATALOG] Athena data catalog holding the rum tables.
282
+ Names the catalog both in the query and in the context it runs in, so the two always
283
+ agree
277
284
  --excludeNoindex Exclude page impressions of pages marked noindex
278
285
  --excludeSuspicious Exclude page impressions Speed Kit classified as suspicious
279
286
  --explain Account for every rule the query leaves out, reduces or approximates
@@ -302,9 +309,9 @@ Build a prewarm query that can be executed in Athena
302
309
 
303
310
  ```
304
311
  USAGE
305
- $ sk build-prewarm-query CUSTOMERPATH [--workgroup <value>] [-c <value>] [-d <value>] [--excludeNoindex]
306
- [--excludeSuspicious] [--explain] [--hardNavigationsOnly] [-q] [--device desktop|mobile|tablet... | -v <value>...]
307
- [--excludeNotFound] [--limit <value>] [--match <value>...] [--minHits <value>] [--count | -e]
312
+ $ sk build-prewarm-query CUSTOMERPATH [--catalog <value>] [--workgroup <value>] [-c <value>] [-d <value>]
313
+ [--excludeNoindex] [--excludeSuspicious] [--explain] [--hardNavigationsOnly] [-q] [--device desktop|mobile|tablet...
314
+ | -v <value>...] [--excludeNotFound] [--limit <value>] [--match <value>...] [--minHits <value>] [--count | -e]
308
315
 
309
316
  ARGUMENTS
310
317
  CUSTOMERPATH The customer config path
@@ -316,6 +323,8 @@ FLAGS
316
323
  -q, --quiet Output only the raw query without clipboard copy or styling
317
324
  -v, --variation=<value>... Pair every url of the list with each of these variations. For the custom variations no
318
325
  traffic can reveal, e.g. 'desktop-pdp-a,mobile-pdp-a'
326
+ --catalog=<value> [default: live, env: SK_ATHENA_CATALOG] Athena data catalog holding the rum tables. Names
327
+ the catalog both in the query and in the context it runs in, so the two always agree
319
328
  --count Run the query and report how large the list is instead of what is in it: urls, the
320
329
  impressions they stand for, and a row per variation where the list carries variations.
321
330
  Every other flag still applies, so this counts the list the same command would produce
@@ -838,8 +847,9 @@ Build a parameter query that can be executed in Athena
838
847
 
839
848
  ```
840
849
  USAGE
841
- $ sk query parameter CUSTOMERPATH [--workgroup <value>] [-c <value>] [-d <value>] [--excludeNoindex]
842
- [--excludeSuspicious] [--explain] [--hardNavigationsOnly] [-e] [-q] [--bySet] [--minImpressions <value>]
850
+ $ sk query parameter CUSTOMERPATH [--catalog <value>] [--workgroup <value>] [-c <value>] [-d <value>]
851
+ [--excludeNoindex] [--excludeSuspicious] [--explain] [--hardNavigationsOnly] [-e] [-q] [--bySet] [--minImpressions
852
+ <value>]
843
853
 
844
854
  ARGUMENTS
845
855
  CUSTOMERPATH The customer config path
@@ -851,6 +861,9 @@ FLAGS
851
861
  -q, --quiet Output only the raw query without clipboard copy or styling
852
862
  --bySet Rank whole sets of parameters instead of single parameters, to see which ones travel
853
863
  together
864
+ --catalog=<value> [default: live, env: SK_ATHENA_CATALOG] Athena data catalog holding the rum tables.
865
+ Names the catalog both in the query and in the context it runs in, so the two always
866
+ agree
854
867
  --excludeNoindex Exclude page impressions of pages marked noindex
855
868
  --excludeSuspicious Exclude page impressions Speed Kit classified as suspicious
856
869
  --explain Account for every rule the query leaves out, reduces or approximates
@@ -879,9 +892,9 @@ Build a prewarm query that can be executed in Athena
879
892
 
880
893
  ```
881
894
  USAGE
882
- $ sk query prewarm CUSTOMERPATH [--workgroup <value>] [-c <value>] [-d <value>] [--excludeNoindex]
883
- [--excludeSuspicious] [--explain] [--hardNavigationsOnly] [-q] [--device desktop|mobile|tablet... | -v <value>...]
884
- [--excludeNotFound] [--limit <value>] [--match <value>...] [--minHits <value>] [--count | -e]
895
+ $ sk query prewarm CUSTOMERPATH [--catalog <value>] [--workgroup <value>] [-c <value>] [-d <value>]
896
+ [--excludeNoindex] [--excludeSuspicious] [--explain] [--hardNavigationsOnly] [-q] [--device desktop|mobile|tablet...
897
+ | -v <value>...] [--excludeNotFound] [--limit <value>] [--match <value>...] [--minHits <value>] [--count | -e]
885
898
 
886
899
  ARGUMENTS
887
900
  CUSTOMERPATH The customer config path
@@ -893,6 +906,8 @@ FLAGS
893
906
  -q, --quiet Output only the raw query without clipboard copy or styling
894
907
  -v, --variation=<value>... Pair every url of the list with each of these variations. For the custom variations no
895
908
  traffic can reveal, e.g. 'desktop-pdp-a,mobile-pdp-a'
909
+ --catalog=<value> [default: live, env: SK_ATHENA_CATALOG] Athena data catalog holding the rum tables. Names
910
+ the catalog both in the query and in the context it runs in, so the two always agree
896
911
  --count Run the query and report how large the list is instead of what is in it: urls, the
897
912
  impressions they stand for, and a row per variation where the list carries variations.
898
913
  Every other flag still applies, so this counts the list the same command would produce
@@ -24,6 +24,7 @@ export default class AutoPrewarm extends Command {
24
24
  hardNavigationsOnly: import("@oclif/core/interfaces").BooleanFlag<boolean>;
25
25
  days: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
26
26
  configName: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
27
+ catalog: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
27
28
  workgroup: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
28
29
  };
29
30
  run(): Promise<void>;
@@ -46,7 +46,7 @@ export default class AutoPrewarm extends Command {
46
46
  }),
47
47
  };
48
48
  async run() {
49
- const { args: { customerPath }, flags: { configName, days, device, excludeNoindex, excludeNotFound, excludeSuspicious, hardNavigationsOnly, keepParameterSorting, limit, match, minHits, quiet, variation, variationPath, verbose, workgroup, }, } = await this.parse(AutoPrewarm);
49
+ const { args: { customerPath }, flags: { configName, days, device, excludeNoindex, excludeNotFound, excludeSuspicious, hardNavigationsOnly, keepParameterSorting, limit, match, minHits, quiet, variation, variationPath, verbose, workgroup, catalog, }, } = await this.parse(AutoPrewarm);
50
50
  const service = await new AutoPreWarmFactory({
51
51
  customerPath,
52
52
  configName,
@@ -65,6 +65,7 @@ export default class AutoPrewarm extends Command {
65
65
  quiet,
66
66
  verbose,
67
67
  workgroup,
68
+ catalog,
68
69
  isWindows: this.config.windows,
69
70
  }, new CliConfig(this.config).load()).getService();
70
71
  await service.run();
@@ -21,6 +21,7 @@ export default class QueryParameter extends Command {
21
21
  hardNavigationsOnly: import("@oclif/core/interfaces").BooleanFlag<boolean>;
22
22
  days: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
23
23
  configName: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
24
+ catalog: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
24
25
  workgroup: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
25
26
  };
26
27
  run(): Promise<void>;
@@ -26,6 +26,7 @@ export default class QueryPrewarm extends Command {
26
26
  hardNavigationsOnly: import("@oclif/core/interfaces").BooleanFlag<boolean>;
27
27
  days: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
28
28
  configName: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
29
+ catalog: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
29
30
  workgroup: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
30
31
  };
31
32
  run(): Promise<void>;
@@ -14,6 +14,12 @@
14
14
  * behind a timeout, so no clipboard tool can ever be the reason a command
15
15
  * appears to do nothing.
16
16
  *
17
+ * `clipboardy` is loaded here rather than at module scope so that importing
18
+ * anything that can copy does not drag it in. It pulls a dependency that some
19
+ * Node/loader combinations refuse to resolve, which made every module on this
20
+ * path impossible to import from a test — and a clipboard that cannot load is
21
+ * already the case this function answers with `false`.
22
+ *
17
23
  * @returns whether the text reached the clipboard.
18
24
  */
19
25
  export declare function copyToClipboard(text: string): Promise<boolean>;
@@ -1,5 +1,4 @@
1
1
  import { spawn } from "node:child_process";
2
- import clipboardy from "clipboardy";
3
2
  /** How long a clipboard tool may take before the CLI stops waiting for it. */
4
3
  const TIMEOUT_IN_MS = 2000;
5
4
  /**
@@ -18,6 +17,12 @@ const TIMEOUT_IN_MS = 2000;
18
17
  * behind a timeout, so no clipboard tool can ever be the reason a command
19
18
  * appears to do nothing.
20
19
  *
20
+ * `clipboardy` is loaded here rather than at module scope so that importing
21
+ * anything that can copy does not drag it in. It pulls a dependency that some
22
+ * Node/loader combinations refuse to resolve, which made every module on this
23
+ * path impossible to import from a test — and a clipboard that cannot load is
24
+ * already the case this function answers with `false`.
25
+ *
21
26
  * @returns whether the text reached the clipboard.
22
27
  */
23
28
  export async function copyToClipboard(text) {
@@ -28,6 +33,7 @@ export async function copyToClipboard(text) {
28
33
  }
29
34
  }
30
35
  try {
36
+ const { default: clipboardy } = await import("clipboardy");
31
37
  return await withTimeout(clipboardy.write(text).then(() => true));
32
38
  }
33
39
  catch {
@@ -3,6 +3,7 @@ export declare enum CLIParameters {
3
3
  Artifacts = "artifacts",
4
4
  ConfigName = "configName",
5
5
  ContentTypes = "contentTypes",
6
+ Catalog = "catalog",
6
7
  Cookies = "cookies",
7
8
  Count = "count",
8
9
  CreateTests = "CreateTests",
@@ -170,6 +171,10 @@ export declare const CLI_QUERY_PREWARM: {
170
171
  /**
171
172
  * Where a query that is executed actually runs.
172
173
  *
174
+ * The catalog is the account's, not the query's: our own logins reach the rum
175
+ * tables under `live`, while a role scoped to one account reaches the same
176
+ * tables under `AwsDataCatalog`. `SK_ATHENA_CATALOG` lets a CI job say so once.
177
+ *
173
178
  * Athena resolves the account's default workgroup when none is named, which is
174
179
  * only right where the credentials are allowed to use it — a role scoped to one
175
180
  * workgroup is refused on any other, and the result location is a workgroup
@@ -177,6 +182,7 @@ export declare const CLI_QUERY_PREWARM: {
177
182
  * command in the job.
178
183
  */
179
184
  export declare const CLI_ATHENA: {
185
+ catalog: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
180
186
  workgroup: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
181
187
  };
182
188
  /** How the parameter query presents what it found. */
@@ -4,12 +4,14 @@ import { Args, Flags } from "@oclif/core";
4
4
  // library.
5
5
  import { parseMatchPattern } from "../services/query-builder/rules/match-pattern.js";
6
6
  import { DEVICE_VARIATIONS } from "../services/query-builder/queries/device.js";
7
+ import { DEFAULT_ATHENA_CATALOG } from "../services/athena/athena-service-model.js";
7
8
  export var CLIParameters;
8
9
  (function (CLIParameters) {
9
10
  CLIParameters["AppName"] = "app";
10
11
  CLIParameters["Artifacts"] = "artifacts";
11
12
  CLIParameters["ConfigName"] = "configName";
12
13
  CLIParameters["ContentTypes"] = "contentTypes";
14
+ CLIParameters["Catalog"] = "catalog";
13
15
  CLIParameters["Cookies"] = "cookies";
14
16
  CLIParameters["Count"] = "count";
15
17
  CLIParameters["CreateTests"] = "CreateTests";
@@ -253,6 +255,10 @@ export const CLI_QUERY_PREWARM = {
253
255
  /**
254
256
  * Where a query that is executed actually runs.
255
257
  *
258
+ * The catalog is the account's, not the query's: our own logins reach the rum
259
+ * tables under `live`, while a role scoped to one account reaches the same
260
+ * tables under `AwsDataCatalog`. `SK_ATHENA_CATALOG` lets a CI job say so once.
261
+ *
256
262
  * Athena resolves the account's default workgroup when none is named, which is
257
263
  * only right where the credentials are allowed to use it — a role scoped to one
258
264
  * workgroup is refused on any other, and the result location is a workgroup
@@ -260,6 +266,12 @@ export const CLI_QUERY_PREWARM = {
260
266
  * command in the job.
261
267
  */
262
268
  export const CLI_ATHENA = {
269
+ [CLIParameters.Catalog]: Flags.string({
270
+ default: DEFAULT_ATHENA_CATALOG,
271
+ description: "Athena data catalog holding the rum tables. Names the catalog both in the query and in the context it runs in, so the two always agree",
272
+ env: "SK_ATHENA_CATALOG",
273
+ required: false,
274
+ }),
263
275
  [CLIParameters.Workgroup]: Flags.string({
264
276
  description: "Athena workgroup to run the query in, e.g. 'prewarming'. Defaults to the account's default workgroup",
265
277
  env: "SK_ATHENA_WORKGROUP",
@@ -2,6 +2,7 @@ import { AthenaService } from "./athena-query-service.js";
2
2
  export declare class AthenaServiceFactory {
3
3
  private app?;
4
4
  private workgroup?;
5
+ private catalog?;
5
6
  service?: AthenaService;
6
7
  /**
7
8
  * @param app - the app to query. Enables the Baqend native query path (app
@@ -9,8 +10,11 @@ export declare class AthenaServiceFactory {
9
10
  * the AWS path is available.
10
11
  * @param workgroup - Athena workgroup for the AWS path. The Baqend path has
11
12
  * no such notion, so it is ignored there.
13
+ * @param catalog - the data catalog the tables live in. The Baqend path is
14
+ * told so it can recognise the prefix, not so it can use it: the server
15
+ * resolves the catalog itself.
12
16
  */
13
- constructor(app?: string, workgroup?: string);
17
+ constructor(app?: string, workgroup?: string, catalog?: string);
14
18
  getService(): Promise<AthenaService>;
15
19
  /**
16
20
  * Prefer direct AWS Athena whenever AWS credentials are present — they
@@ -9,6 +9,7 @@ import { EntityManagerFactory } from "../config-api/entity-manager-factory.js";
9
9
  export class AthenaServiceFactory {
10
10
  app;
11
11
  workgroup;
12
+ catalog;
12
13
  service;
13
14
  /**
14
15
  * @param app - the app to query. Enables the Baqend native query path (app
@@ -16,10 +17,14 @@ export class AthenaServiceFactory {
16
17
  * the AWS path is available.
17
18
  * @param workgroup - Athena workgroup for the AWS path. The Baqend path has
18
19
  * no such notion, so it is ignored there.
20
+ * @param catalog - the data catalog the tables live in. The Baqend path is
21
+ * told so it can recognise the prefix, not so it can use it: the server
22
+ * resolves the catalog itself.
19
23
  */
20
- constructor(app, workgroup) {
24
+ constructor(app, workgroup, catalog) {
21
25
  this.app = app;
22
26
  this.workgroup = workgroup;
27
+ this.catalog = catalog;
23
28
  }
24
29
  async getService() {
25
30
  if (!this.service) {
@@ -40,10 +45,10 @@ export class AthenaServiceFactory {
40
45
  cli.comment(this.workgroup
41
46
  ? `Using AWS credentials for Athena queries, workgroup ${this.workgroup}.`
42
47
  : "Using AWS credentials for Athena queries.");
43
- return new AwsAthenaService(client, cli, this.workgroup);
48
+ return new AwsAthenaService(client, cli, this.workgroup, this.catalog);
44
49
  }
45
50
  if (this.app) {
46
- return new BaqendAthenaService(this.app, new EntityManagerFactory(), cli);
51
+ return new BaqendAthenaService(this.app, new EntityManagerFactory(), cli, this.catalog);
47
52
  }
48
53
  throw new FetchCredentialsError("Cannot send Athena queries: no AWS credentials found and no app given for the Baqend API.");
49
54
  }
@@ -1,4 +1,21 @@
1
1
  export declare const DEFAULT_MAX_AGE_IN_MINUTES = 60;
2
+ /**
3
+ * The Athena data catalog holding the Speed Kit tables.
4
+ *
5
+ * Which catalog that is depends on the credentials: the name below is the one
6
+ * our own logins see, while a role scoped to one account reaches the same
7
+ * tables under `AwsDataCatalog`. It is therefore a default, not a constant —
8
+ * see the `--catalog` flag.
9
+ */
10
+ export declare const DEFAULT_ATHENA_CATALOG = "live";
11
+ /**
12
+ * The catalog to use, given what a caller asked for.
13
+ *
14
+ * A blank name is the default rather than an error: it reaches here from an
15
+ * unset environment variable in a CI file as easily as from a typo, and a
16
+ * query that reads `from .rum.pi` is no way to report it.
17
+ */
18
+ export declare function athenaCatalog(catalog?: string): string;
2
19
  export declare const ATHENA_CONFIG: {
3
20
  region: string;
4
21
  };
@@ -1,4 +1,23 @@
1
1
  export const DEFAULT_MAX_AGE_IN_MINUTES = 60;
2
+ /**
3
+ * The Athena data catalog holding the Speed Kit tables.
4
+ *
5
+ * Which catalog that is depends on the credentials: the name below is the one
6
+ * our own logins see, while a role scoped to one account reaches the same
7
+ * tables under `AwsDataCatalog`. It is therefore a default, not a constant —
8
+ * see the `--catalog` flag.
9
+ */
10
+ export const DEFAULT_ATHENA_CATALOG = "live";
11
+ /**
12
+ * The catalog to use, given what a caller asked for.
13
+ *
14
+ * A blank name is the default rather than an error: it reaches here from an
15
+ * unset environment variable in a CI file as easily as from a typo, and a
16
+ * query that reads `from .rum.pi` is no way to report it.
17
+ */
18
+ export function athenaCatalog(catalog) {
19
+ return catalog?.trim() || DEFAULT_ATHENA_CATALOG;
20
+ }
2
21
  export const ATHENA_CONFIG = {
3
22
  region: "eu-central-1",
4
23
  };
@@ -15,12 +15,22 @@ export declare class AwsAthenaService implements AthenaService {
15
15
  * which is only right where the credentials are allowed to use it.
16
16
  */
17
17
  private workgroup?;
18
+ /**
19
+ * The data catalog the query runs against. It has to be the one the query
20
+ * text names too, or Athena rejects the reference rather than the context.
21
+ */
22
+ private catalog?;
18
23
  constructor(client: AthenaClient, cli: CliServiceInterface,
19
24
  /**
20
25
  * Athena falls back to the account's default workgroup when this is unset,
21
26
  * which is only right where the credentials are allowed to use it.
22
27
  */
23
- workgroup?: string);
28
+ workgroup?: string,
29
+ /**
30
+ * The data catalog the query runs against. It has to be the one the query
31
+ * text names too, or Athena rejects the reference rather than the context.
32
+ */
33
+ catalog?: string);
24
34
  getResult<T>(query: string, parameters?: string[], maxAgeInMinutes?: number): Promise<T[]>;
25
35
  /** The query, from sending it off to the rows it produced. */
26
36
  private runAndFetch;
@@ -1,5 +1,5 @@
1
1
  import { GetQueryExecutionCommand, GetQueryResultsCommand, QueryExecutionState, StartQueryExecutionCommand, } from "@aws-sdk/client-athena";
2
- import { DEFAULT_MAX_AGE_IN_MINUTES } from "./athena-service-model.js";
2
+ import { athenaCatalog, DEFAULT_MAX_AGE_IN_MINUTES, } from "./athena-service-model.js";
3
3
  import { AthenaQueryError } from "./error/athena-query-error.js";
4
4
  import { safe } from "../../helpers/safe.js";
5
5
  /**
@@ -12,15 +12,22 @@ export class AwsAthenaService {
12
12
  client;
13
13
  cli;
14
14
  workgroup;
15
+ catalog;
15
16
  constructor(client, cli,
16
17
  /**
17
18
  * Athena falls back to the account's default workgroup when this is unset,
18
19
  * which is only right where the credentials are allowed to use it.
19
20
  */
20
- workgroup) {
21
+ workgroup,
22
+ /**
23
+ * The data catalog the query runs against. It has to be the one the query
24
+ * text names too, or Athena rejects the reference rather than the context.
25
+ */
26
+ catalog) {
21
27
  this.client = client;
22
28
  this.cli = cli;
23
29
  this.workgroup = workgroup;
30
+ this.catalog = catalog;
24
31
  }
25
32
  async getResult(query, parameters = [], maxAgeInMinutes = DEFAULT_MAX_AGE_IN_MINUTES) {
26
33
  this.cli.startAction("ATHENA:QUERY", "execute athena query");
@@ -106,7 +113,7 @@ export class AwsAthenaService {
106
113
  const command = new StartQueryExecutionCommand({
107
114
  ExecutionParameters: executionParameters.length > 0 ? executionParameters : null,
108
115
  QueryExecutionContext: {
109
- Catalog: "live",
116
+ Catalog: athenaCatalog(this.catalog),
110
117
  },
111
118
  QueryString: sql,
112
119
  ResultReuseConfiguration: {
@@ -81,6 +81,22 @@ describe("AwsAthenaService", () => {
81
81
  const start = sent.find((command) => command instanceof StartQueryExecutionCommand);
82
82
  expect(start.input).to.not.have.property("WorkGroup");
83
83
  });
84
+ it("should run against the catalog it was given", async () => {
85
+ // The context and the query text have to name the same catalog, or Athena
86
+ // rejects the reference in the query rather than the context.
87
+ const sent = [];
88
+ const service = new AwsAthenaService(clientAnswering("SUCCEEDED", undefined, sent), recordingCli().cli, undefined, "AwsDataCatalog");
89
+ await service.getResult("select 1", [], 0);
90
+ const [start] = sent.filter((command) => command instanceof StartQueryExecutionCommand);
91
+ expect(start.input.QueryExecutionContext.Catalog).to.equal("AwsDataCatalog");
92
+ });
93
+ it("should fall back to the live catalog", async () => {
94
+ const sent = [];
95
+ const service = new AwsAthenaService(clientAnswering("SUCCEEDED", undefined, sent), recordingCli().cli);
96
+ await service.getResult("select 1", [], 0);
97
+ const [start] = sent.filter((command) => command instanceof StartQueryExecutionCommand);
98
+ expect(start.input.QueryExecutionContext.Catalog).to.equal("live");
99
+ });
84
100
  it("should not pass a failed query off as an empty result", async () => {
85
101
  const { cli } = recordingCli();
86
102
  const service = new AwsAthenaService(clientAnswering("CANCELLED"), cli);
@@ -8,8 +8,8 @@ import { CliServiceInterface } from "../cli/index.js";
8
8
  *
9
9
  * Two normalizations vs the AWS path:
10
10
  * - `?` placeholders are inlined (the native path has no parameter binding).
11
- * - the `live.` catalog prefix is stripped — the server derives catalog and
12
- * database itself, and a 3-part `live.<db>.<table>` reference fails there
11
+ * - the catalog prefix is stripped — the server derives catalog and database
12
+ * itself, and a 3-part `<catalog>.<db>.<table>` reference fails there
13
13
  * (verified against a live app), whereas 2-part `<db>.<table>` works.
14
14
  */
15
15
  export declare class BaqendAthenaService implements AthenaService {
@@ -17,8 +17,17 @@ export declare class BaqendAthenaService implements AthenaService {
17
17
  private entityManagerFactory;
18
18
  private cli;
19
19
  private static readonly TRIGGERED_BY;
20
- private static readonly CATALOG_PREFIX;
21
- constructor(app: string, entityManagerFactory: EntityManagerFactory, cli: CliServiceInterface);
20
+ /** A catalog qualifier in front of a `<db>.<table>` reference. */
21
+ private readonly catalogPrefix;
22
+ /** The catalog whose qualifier has to come back out of the query. */
23
+ private readonly catalog;
24
+ constructor(app: string, entityManagerFactory: EntityManagerFactory, cli: CliServiceInterface,
25
+ /**
26
+ * The catalog the queries name. Whichever one the caller runs under, the
27
+ * prefix has to come back out here — so the name is read rather than
28
+ * assumed.
29
+ */
30
+ catalog?: string);
22
31
  getResult<T>(query: string, parameters?: AthenaParameter[], maxAgeInMinutes?: number, bucket?: AthenaBucket): Promise<T[]>;
23
32
  /**
24
33
  * Inline the `?` placeholders with the given parameters (same quoting as the
@@ -1,5 +1,9 @@
1
- import { DEFAULT_MAX_AGE_IN_MINUTES } from "./athena-service-model.js";
1
+ import { athenaCatalog, DEFAULT_MAX_AGE_IN_MINUTES, } from "./athena-service-model.js";
2
2
  import { safe } from "../../helpers/safe.js";
3
+ /** A literal for use inside a regular expression. */
4
+ function escapeRegExp(value) {
5
+ return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
6
+ }
3
7
  /**
4
8
  * Runs Athena SQL through the Baqend API, authenticated by the app token only
5
9
  * (no AWS login). Hits the native query path via
@@ -7,8 +11,8 @@ import { safe } from "../../helpers/safe.js";
7
11
  *
8
12
  * Two normalizations vs the AWS path:
9
13
  * - `?` placeholders are inlined (the native path has no parameter binding).
10
- * - the `live.` catalog prefix is stripped — the server derives catalog and
11
- * database itself, and a 3-part `live.<db>.<table>` reference fails there
14
+ * - the catalog prefix is stripped — the server derives catalog and database
15
+ * itself, and a 3-part `<catalog>.<db>.<table>` reference fails there
12
16
  * (verified against a live app), whereas 2-part `<db>.<table>` works.
13
17
  */
14
18
  export class BaqendAthenaService {
@@ -16,12 +20,22 @@ export class BaqendAthenaService {
16
20
  entityManagerFactory;
17
21
  cli;
18
22
  static TRIGGERED_BY = "speed-kit-cli";
19
- // a `live.` catalog qualifier in front of a `<db>.<table>` reference
20
- static CATALOG_PREFIX = /^live\.[a-z_]\w*\.[a-z_]/i;
21
- constructor(app, entityManagerFactory, cli) {
23
+ /** A catalog qualifier in front of a `<db>.<table>` reference. */
24
+ catalogPrefix;
25
+ /** The catalog whose qualifier has to come back out of the query. */
26
+ catalog;
27
+ constructor(app, entityManagerFactory, cli,
28
+ /**
29
+ * The catalog the queries name. Whichever one the caller runs under, the
30
+ * prefix has to come back out here — so the name is read rather than
31
+ * assumed.
32
+ */
33
+ catalog) {
22
34
  this.app = app;
23
35
  this.entityManagerFactory = entityManagerFactory;
24
36
  this.cli = cli;
37
+ this.catalog = athenaCatalog(catalog);
38
+ this.catalogPrefix = new RegExp(`^${escapeRegExp(this.catalog)}\\.[a-z_]\\w*\\.[a-z_]`, "i");
25
39
  }
26
40
  async getResult(query, parameters = [], maxAgeInMinutes = DEFAULT_MAX_AGE_IN_MINUTES, bucket) {
27
41
  if (!bucket) {
@@ -78,11 +92,12 @@ export class BaqendAthenaService {
78
92
  out += typeof value === "string" ? `'${value}'` : String(value);
79
93
  continue;
80
94
  }
81
- // strip a `live.` catalog qualifier at a word boundary
82
- if ((char === "l" || char === "L") &&
95
+ // strip the catalog qualifier at a word boundary
96
+ if (char.toLowerCase() === this.catalog[0].toLowerCase() &&
83
97
  (index === 0 || !/\w/.test(query[index - 1])) &&
84
- BaqendAthenaService.CATALOG_PREFIX.test(query.slice(index))) {
85
- index += 4; // skip "live." (the loop's index++ skips the dot)
98
+ this.catalogPrefix.test(query.slice(index))) {
99
+ // skip the name; the loop's index++ skips the dot behind it
100
+ index += this.catalog.length;
86
101
  continue;
87
102
  }
88
103
  out += char;
@@ -0,0 +1,51 @@
1
+ import { expect } from "chai";
2
+ import { describe, it } from "mocha";
3
+ import { AthenaBucket } from "./athena-query-service.js";
4
+ import { BaqendAthenaService } from "./baqend-athena-service.js";
5
+ const cli = {
6
+ failAction: () => undefined,
7
+ startAction: () => undefined,
8
+ successAction: () => undefined,
9
+ writeError: () => undefined,
10
+ };
11
+ /** An entity manager that records the sql it was asked to run. */
12
+ function factoryCapturing(sent) {
13
+ return {
14
+ getEntityManager: async () => ({
15
+ [AthenaBucket.RumPi]: {
16
+ executeQuery: async (sql) => {
17
+ sent.push(sql);
18
+ return [];
19
+ },
20
+ },
21
+ }),
22
+ };
23
+ }
24
+ /** The sql that reached the Baqend API for a query run under `catalog`. */
25
+ async function sentFor(query, catalog) {
26
+ const sent = [];
27
+ const service = new BaqendAthenaService("shop-de", factoryCapturing(sent), cli, catalog);
28
+ await service.getResult(query, [], 60, AthenaBucket.RumPi);
29
+ return sent[0];
30
+ }
31
+ describe("BaqendAthenaService", () => {
32
+ it("should strip the default catalog qualifier", async () => {
33
+ // The native path derives catalog and database itself and rejects a
34
+ // three-part reference.
35
+ expect(await sentFor("select url from live.rum.pi")).to.equal("select url from rum.pi");
36
+ });
37
+ it("should strip whichever catalog the query names", async () => {
38
+ expect(await sentFor("select url from AwsDataCatalog.rum.pi", "AwsDataCatalog")).to.equal("select url from rum.pi");
39
+ });
40
+ it("should leave a catalog name inside a string literal alone", async () => {
41
+ expect(await sentFor("select 'https://live.foo.com' from live.rum.pi")).to.equal("select 'https://live.foo.com' from rum.pi");
42
+ });
43
+ it("should fall back to the default catalog when given a blank one", async () => {
44
+ // An unset variable in a CI file arrives as an empty string, and the
45
+ // stripping reads the first character of the name.
46
+ expect(await sentFor("select url from live.rum.pi", " ")).to.equal("select url from rum.pi");
47
+ });
48
+ it("should leave a word that merely starts like the catalog alone", async () => {
49
+ expect(await sentFor("select liveliness from live.rum.pi")).to.equal("select liveliness from rum.pi");
50
+ });
51
+ });
@@ -8,6 +8,9 @@ export declare class CliService implements CliServiceInterface {
8
8
  private buffer;
9
9
  private progressBar?;
10
10
  private spinners;
11
+ private readonly animates;
12
+ /** The text of each running action, for the log line that replaces it. */
13
+ private readonly actions;
11
14
  constructor(quiet?: boolean);
12
15
  code(code: string, buffered?: boolean): void;
13
16
  codeStyle(code: string): string;
@@ -30,6 +33,12 @@ export declare class CliService implements CliServiceInterface {
30
33
  endAction(name: string, message?: string): void;
31
34
  failAction(name: string, message?: string): void;
32
35
  successAction(name: string, message?: string): void;
36
+ /**
37
+ * One line for an action that ended, where a spinner cannot be drawn. The
38
+ * text comes from the action that started, so a caller that ends one without
39
+ * a message still says which one it ended.
40
+ */
41
+ private logAction;
33
42
  startProgress(total: number, start?: number, payload?: object, config?: Options): void;
34
43
  updateProgress(current: number, payload?: object): void;
35
44
  stopProgress(): void;
@@ -6,12 +6,27 @@ import Spinnies from "spinnies";
6
6
  import { safe } from "../../helpers/safe.js";
7
7
  import { execSync } from "node:child_process";
8
8
  import ApplicationError from "../error-handling/error/application-error.js";
9
+ /**
10
+ * Whether a spinner can actually spin here.
11
+ *
12
+ * The same condition spinnies itself uses to decide, so the two never
13
+ * disagree: without it spinnies falls back to re-rendering *every* spinner it
14
+ * knows on every update, which in a CI log means the first action's line is
15
+ * printed again under each later one — a page of "build prewarm-query" for a
16
+ * job that only ever built one.
17
+ */
18
+ function canAnimate() {
19
+ return Boolean(!process.env.CI && process.stderr && process.stderr.isTTY);
20
+ }
9
21
  export class CliService {
10
22
  quiet;
11
23
  style;
12
24
  buffer = [];
13
25
  progressBar;
14
26
  spinners;
27
+ animates = canAnimate();
28
+ /** The text of each running action, for the log line that replaces it. */
29
+ actions = new Map();
15
30
  constructor(quiet = false) {
16
31
  this.quiet = quiet;
17
32
  this.spinners = new Spinnies();
@@ -90,26 +105,65 @@ export class CliService {
90
105
  if (this.quiet) {
91
106
  return;
92
107
  }
108
+ // Nothing is logged while the action runs: a log that cannot animate has
109
+ // no use for "this is happening", only for what happened.
110
+ if (!this.animates) {
111
+ this.actions.set(name, message);
112
+ return;
113
+ }
93
114
  this.spinners.add(name, { text: message });
94
115
  }
95
116
  endAction(name, message) {
96
- if (this.quiet || !this.spinners.pick(name)) {
117
+ if (this.quiet) {
118
+ return;
119
+ }
120
+ if (!this.animates) {
121
+ this.logAction("-", name, message);
122
+ return;
123
+ }
124
+ if (!this.spinners.pick(name)) {
97
125
  return;
98
126
  }
99
127
  this.spinners.update(name, { status: "stopped", text: message });
100
128
  }
101
129
  failAction(name, message) {
102
- if (this.quiet || !this.spinners.pick(name)) {
130
+ if (this.quiet) {
131
+ return;
132
+ }
133
+ if (!this.animates) {
134
+ this.logAction(this.style.red("✖"), name, message);
135
+ return;
136
+ }
137
+ if (!this.spinners.pick(name)) {
103
138
  return;
104
139
  }
105
140
  this.spinners.fail(name, { text: message });
106
141
  }
107
142
  successAction(name, message) {
108
- if (this.quiet || !this.spinners.pick(name)) {
143
+ if (this.quiet) {
144
+ return;
145
+ }
146
+ if (!this.animates) {
147
+ this.logAction(this.style.green("✔"), name, message);
148
+ return;
149
+ }
150
+ if (!this.spinners.pick(name)) {
109
151
  return;
110
152
  }
111
153
  this.spinners.succeed(name, { text: message, succeedColor: "white" });
112
154
  }
155
+ /**
156
+ * One line for an action that ended, where a spinner cannot be drawn. The
157
+ * text comes from the action that started, so a caller that ends one without
158
+ * a message still says which one it ended.
159
+ */
160
+ logAction(symbol, name, message) {
161
+ const text = message ?? this.actions.get(name);
162
+ if (!this.actions.delete(name) && !message) {
163
+ return;
164
+ }
165
+ console.log(`${symbol} ${text}`);
166
+ }
113
167
  startProgress(total, start = 0, payload, config) {
114
168
  if (this.quiet) {
115
169
  return;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { expect } from "chai";
2
+ import { afterEach, beforeEach, describe, it } from "mocha";
3
+ import { CliService } from "./cli-service.js";
4
+ /** The lines a block of work put on stdout. */
5
+ function captured(work) {
6
+ const lines = [];
7
+ const log = console.log;
8
+ console.log = (line) => lines.push(line);
9
+ try {
10
+ work();
11
+ }
12
+ finally {
13
+ console.log = log;
14
+ }
15
+ return lines;
16
+ }
17
+ describe("CliService actions without a terminal", () => {
18
+ let ci;
19
+ beforeEach(() => {
20
+ ci = process.env.CI;
21
+ // What a CI runner sets, and what spinnies reads to decide it cannot spin.
22
+ process.env.CI = "true";
23
+ });
24
+ afterEach(() => {
25
+ if (ci === undefined) {
26
+ delete process.env.CI;
27
+ return;
28
+ }
29
+ process.env.CI = ci;
30
+ });
31
+ it("should say nothing while an action runs", () => {
32
+ const cli = new CliService();
33
+ expect(captured(() => cli.startAction("BUILD", "build prewarm-query"))).to.have.lengthOf(0);
34
+ });
35
+ it("should log an action that ended exactly once", () => {
36
+ // Spinnies re-renders every spinner it knows on each update when it cannot
37
+ // animate, so a running action reappears under each later one — a job with
38
+ // four actions logged "build prewarm-query" four times.
39
+ const cli = new CliService();
40
+ const lines = captured(() => {
41
+ cli.startAction("BUILD", "build prewarm-query");
42
+ cli.successAction("BUILD");
43
+ cli.startAction("QUERY", "execute athena query");
44
+ cli.failAction("QUERY");
45
+ });
46
+ expect(lines).to.have.lengthOf(2);
47
+ expect(lines[0]).to.contain("build prewarm-query");
48
+ expect(lines[1]).to.contain("execute athena query");
49
+ expect(lines.join("\n")).to.not.contain("build prewarm-query\n✖ execute athena query\n✔ build prewarm-query");
50
+ });
51
+ it("should keep the text of the action it ends", () => {
52
+ const cli = new CliService();
53
+ const lines = captured(() => {
54
+ cli.startAction("WARM", "warm the list");
55
+ cli.endAction("WARM");
56
+ });
57
+ expect(lines).to.deep.equal(["- warm the list"]);
58
+ });
59
+ it("should stay silent when it was told to be quiet", () => {
60
+ const cli = new CliService(true);
61
+ const lines = captured(() => {
62
+ cli.startAction("BUILD", "build prewarm-query");
63
+ cli.successAction("BUILD");
64
+ });
65
+ expect(lines).to.have.lengthOf(0);
66
+ });
67
+ });
@@ -16,6 +16,8 @@ export interface AutoPreWarmContext extends QueryFilterOptions {
16
16
  readonly minHits?: number;
17
17
  /** Athena workgroup the query runs in. */
18
18
  readonly workgroup?: string;
19
+ /** Athena data catalog the rum tables live in. */
20
+ readonly catalog?: string;
19
21
  /** Variations to pair every url with, when no traffic can reveal them. */
20
22
  readonly variation?: readonly string[];
21
23
  readonly variationPath?: string;
@@ -45,6 +45,7 @@ export class AutoPreWarmFactory {
45
45
  quiet: this.context.quiet,
46
46
  variations: this.context.variation,
47
47
  workgroup: this.context.workgroup,
48
+ catalog: this.context.catalog,
48
49
  }, this.userConfig).buildService(QueryType.prewarm);
49
50
  const app = queryService.app;
50
51
  const rows = await queryService.execute();
@@ -1,7 +1,16 @@
1
1
  import { RuleSubjectExpressions } from "../rules/index.js";
2
2
  import { QueryInput } from "./query-model.js";
3
- /** The relation every query reads from. */
4
- export declare const PAGE_IMPRESSIONS = "live.rum.pi";
3
+ /** The database and table every query reads from, within its catalog. */
4
+ export declare const PAGE_IMPRESSIONS = "rum.pi";
5
+ /**
6
+ * The relation every query reads from, qualified by the catalog it lives in.
7
+ *
8
+ * The catalog is named in the query and not only in the context it runs in, so
9
+ * that a query pasted into the Athena console reads the same tables as the one
10
+ * the CLI ran — the console resolves an unqualified name against whichever
11
+ * catalog its dropdown happens to hold.
12
+ */
13
+ export declare function pageImpressions(catalog?: string): string;
5
14
  /** The name of the common table expression the queries select from. */
6
15
  export declare const PAGE_SOURCE = "pages";
7
16
  export interface PageSource {
@@ -2,8 +2,20 @@ import { column, formatPredicate } from "../sql/index.js";
2
2
  import { resolvedSubject, urlSubjects, } from "../rules/index.js";
3
3
  import { normalizeDevices } from "./device.js";
4
4
  import { buildRowFilter } from "./page-filter.js";
5
- /** The relation every query reads from. */
6
- export const PAGE_IMPRESSIONS = "live.rum.pi";
5
+ import { athenaCatalog } from "../../athena/athena-service-model.js";
6
+ /** The database and table every query reads from, within its catalog. */
7
+ export const PAGE_IMPRESSIONS = "rum.pi";
8
+ /**
9
+ * The relation every query reads from, qualified by the catalog it lives in.
10
+ *
11
+ * The catalog is named in the query and not only in the context it runs in, so
12
+ * that a query pasted into the Athena console reads the same tables as the one
13
+ * the CLI ran — the console resolves an unqualified name against whichever
14
+ * catalog its dropdown happens to hold.
15
+ */
16
+ export function pageImpressions(catalog) {
17
+ return `${athenaCatalog(catalog)}.${PAGE_IMPRESSIONS}`;
18
+ }
7
19
  /** The name of the common table expression the queries select from. */
8
20
  export const PAGE_SOURCE = "pages";
9
21
  const COLUMN_NAMES = {
@@ -37,7 +49,7 @@ export function buildPageSource(input, options = {}) {
37
49
  const cte = `${PAGE_SOURCE} as (
38
50
  select
39
51
  ${projections.join(",\n ")}
40
- from ${PAGE_IMPRESSIONS}
52
+ from ${pageImpressions(input.catalog)}
41
53
  where ${formatPredicate(buildRowFilter(input), { level: 2 })}
42
54
  )`;
43
55
  // An unused subject keeps its inline expression: nothing refers to it, and
@@ -61,6 +61,8 @@ export interface ParameterOptions {
61
61
  /** Everything a query is built from. */
62
62
  export interface QueryInput extends ParameterOptions, PrewarmOptions {
63
63
  readonly app: string;
64
+ /** The data catalog the rum tables live in. Defaults to `live`. */
65
+ readonly catalog?: string;
64
66
  /** Size of the window the query looks at, in days. */
65
67
  readonly days: number;
66
68
  /** Drop page impressions of pages marked `noindex`. */
@@ -188,6 +188,23 @@ describe("the --variation pairing", () => {
188
188
  expect(buildPrewarmQuery(inputFor(CONFIG, { variations: ["it's"] })).sql).to.contain("array['it''s']");
189
189
  });
190
190
  });
191
+ describe("the --catalog the query reads from", () => {
192
+ it("should read the rum tables from the live catalog by default", () => {
193
+ expect(buildPrewarmQuery(inputFor(CONFIG)).sql).to.contain("from live.rum.pi");
194
+ expect(buildParameterQuery(inputFor(CONFIG)).sql).to.contain("from live.rum.pi");
195
+ });
196
+ it("should read from the live catalog when given a blank one", () => {
197
+ expect(buildPrewarmQuery(inputFor(CONFIG, { catalog: "" })).sql).to.contain("from live.rum.pi");
198
+ });
199
+ it("should name the catalog it was given instead", () => {
200
+ // Which catalog holds the tables depends on the credentials: our own
201
+ // logins see them under `live`, a role scoped to one account under
202
+ // `AwsDataCatalog`.
203
+ const options = { catalog: "AwsDataCatalog" };
204
+ expect(buildPrewarmQuery(inputFor(CONFIG, options)).sql).to.contain("from AwsDataCatalog.rum.pi");
205
+ expect(buildParameterQuery(inputFor(CONFIG, options)).sql).to.not.contain("live.rum.pi");
206
+ });
207
+ });
191
208
  describe("the --count report", () => {
192
209
  it("should count the urls and the traffic they stand for", () => {
193
210
  const { sql } = buildPrewarmQuery(inputFor(CONFIG, { count: true }));
@@ -25,6 +25,7 @@ export class QueryBuilderFactory {
25
25
  const serviceContext = {
26
26
  app,
27
27
  bySet: this.context.bySet,
28
+ catalog: this.context.catalog,
28
29
  count: this.context.count,
29
30
  days: this.context.days,
30
31
  devices: this.context.devices,
@@ -47,7 +48,7 @@ export class QueryBuilderFactory {
47
48
  // The Athena service is only needed when the query is executed directly —
48
49
  // which `--count` is, a count query being of no use as text.
49
50
  const athenaService = this.context.execute || this.context.count
50
- ? await new AthenaServiceFactory(app, this.context.workgroup).getService()
51
+ ? await new AthenaServiceFactory(app, this.context.workgroup, this.context.catalog).getService()
51
52
  : undefined;
52
53
  return new QueryBuilderService(serviceContext, parseRuleSets(speedKitConfig), cli, athenaService);
53
54
  }
@@ -16,6 +16,8 @@ export interface QueryFilterOptions {
16
16
  }
17
17
  export interface QueryBuilderContext extends ParameterOptions, PrewarmOptions, QueryFilterOptions {
18
18
  readonly app: string;
19
+ /** Athena data catalog the rum tables live in. */
20
+ readonly catalog?: string;
19
21
  readonly execute?: boolean;
20
22
  /** Report every rule the query leaves out, reduces or approximates. */
21
23
  readonly explain?: boolean;
@@ -26,6 +28,8 @@ export interface QueryBuilderContext extends ParameterOptions, PrewarmOptions, Q
26
28
  readonly quiet?: boolean;
27
29
  }
28
30
  export interface QueryBuilderFactoryContext extends ParameterOptions, PrewarmOptions, QueryFilterOptions {
31
+ /** Athena data catalog the rum tables live in. */
32
+ readonly catalog?: string;
29
33
  readonly configName: string;
30
34
  readonly customerPath: string;
31
35
  readonly execute?: boolean;
@@ -44,10 +44,10 @@ export class QueryBuilderService {
44
44
  this.cli.startAction(action, `build ${this.context.queryType}-query`);
45
45
  const result = safe(() => this.build());
46
46
  if (result.success !== true) {
47
+ // Fail the action before throwing: the spinner is a timer, and one left
48
+ // running keeps the process alive with nothing to do.
47
49
  this.cli.failAction(action, result.error);
48
- this.cli.writeError(result.error);
49
- console.error(result.errorObj);
50
- return;
50
+ throw result.errorObj ?? new Error(result.error);
51
51
  }
52
52
  this.cli.successAction(action);
53
53
  const query = result.data;
@@ -99,6 +99,7 @@ export class QueryBuilderService {
99
99
  build() {
100
100
  const input = {
101
101
  app: this.context.app,
102
+ catalog: this.context.catalog,
102
103
  bySet: this.context.bySet,
103
104
  count: this.context.count,
104
105
  days: this.context.days ?? DEFAULT_QUERY_DAYS[this.context.queryType],
@@ -178,9 +179,12 @@ export class QueryBuilderService {
178
179
  this.cli.startAction("QUERY:COUNT", "count the pre-warm list");
179
180
  const result = await safe(this.runQuery(query));
180
181
  if (result.success !== true) {
182
+ // Thrown, not reported and swallowed. A count that did not run is a
183
+ // failed command, and something driving this from a script — a CI job
184
+ // reading the exit code — has no other way to find that out. Printing
185
+ // the message and exiting 0 made a broken query look like a clean run.
181
186
  this.cli.failAction("QUERY:COUNT");
182
- this.cli.writeError(result.error);
183
- return;
187
+ throw result.errorObj ?? new Error(result.error);
184
188
  }
185
189
  this.cli.successAction("QUERY:COUNT");
186
190
  const rows = result.data;
@@ -210,9 +214,10 @@ export class QueryBuilderService {
210
214
  this.cli.startAction("QUERY:EXECUTE", "execute query and export csv");
211
215
  const result = await safe(this.runQuery(query));
212
216
  if (result.success !== true) {
217
+ // Thrown for the same reason as the count path: an export that produced
218
+ // no file must not exit 0.
213
219
  this.cli.failAction("QUERY:EXECUTE");
214
- this.cli.writeError(result.error);
215
- return;
220
+ throw result.errorObj ?? new Error(result.error);
216
221
  }
217
222
  const rows = result.data;
218
223
  const date = new Date().toISOString().split("T")[0];
@@ -0,0 +1,65 @@
1
+ import { expect } from "chai";
2
+ import { describe, it } from "mocha";
3
+ import { QueryBuilderService } from "./query-builder-service.js";
4
+ import { QueryType } from "./query-builder-model.js";
5
+ import { parseRuleSets } from "./rules/index.js";
6
+ /** Records the actions so a left-running spinner shows up as a missing fail. */
7
+ function recordingCli() {
8
+ const calls = [];
9
+ const cli = {
10
+ comment: () => calls.push("comment"),
11
+ failAction: (name) => calls.push(`fail:${name}`),
12
+ startAction: (name) => calls.push(`start:${name}`),
13
+ successAction: (name) => calls.push(`success:${name}`),
14
+ table: () => calls.push("table"),
15
+ writeError: (message) => calls.push(`error:${message}`),
16
+ writeSuccess: () => calls.push("success"),
17
+ writeWarning: () => calls.push("warning"),
18
+ };
19
+ return { calls, cli };
20
+ }
21
+ /** An Athena that always refuses, the way a bad catalog or workgroup does. */
22
+ const refusingAthena = {
23
+ getResult: () => Promise.reject(new Error("The Amazon Athena query failed to run with error message: CATALOG_NOT_FOUND")),
24
+ };
25
+ function countService(cli) {
26
+ return new QueryBuilderService({
27
+ app: "obi-de",
28
+ count: true,
29
+ hosts: ["www.obi.de"],
30
+ queryType: QueryType.prewarm,
31
+ quiet: true,
32
+ },
33
+ // The real parser on an empty config: a hand-rolled stand-in would fail the
34
+ // build step and the test would pass for the wrong reason.
35
+ parseRuleSets({}), cli, refusingAthena);
36
+ }
37
+ describe("QueryBuilderService — a query that could not run", () => {
38
+ // Regression: this used to print the message and return, so the process
39
+ // exited 0. A CI job counting urls then reported success with no count, and
40
+ // the pipeline that read it had no way to tell a broken query from an empty
41
+ // one.
42
+ it("fails the command instead of exiting cleanly", async () => {
43
+ const { cli } = recordingCli();
44
+ const outcome = await countService(cli)
45
+ .run()
46
+ .then(() => "resolved")
47
+ .catch(() => "rejected");
48
+ expect(outcome).to.equal("rejected");
49
+ });
50
+ it("carries the reason, so the log says what went wrong", async () => {
51
+ const { cli } = recordingCli();
52
+ const error = await countService(cli)
53
+ .run()
54
+ .then(() => undefined)
55
+ .catch((thrown) => thrown);
56
+ expect(error?.message).to.contain("CATALOG_NOT_FOUND");
57
+ });
58
+ it("ends the spinner before throwing — it is a timer that holds the process open", async () => {
59
+ const { calls, cli } = recordingCli();
60
+ await countService(cli)
61
+ .run()
62
+ .catch(() => undefined);
63
+ expect(calls).to.contain("fail:QUERY:COUNT");
64
+ });
65
+ });
@@ -8,6 +8,7 @@ import { QueryFilterOptions, QueryType } from "./query-builder-model.js";
8
8
  * the two the caller gives rather than the list the builder works on.
9
9
  */
10
10
  export interface QueryCommandFlags extends ParameterOptions, Omit<PrewarmOptions, "devices" | "variations">, QueryFilterOptions {
11
+ readonly catalog?: string;
11
12
  readonly configName: string;
12
13
  readonly device?: readonly string[];
13
14
  readonly execute?: boolean;
@@ -12,6 +12,7 @@ import { normalizeDevices } from "./queries/device.js";
12
12
  export async function runQuery(queryType, customerPath, flags, config) {
13
13
  const service = await new QueryBuilderFactory({
14
14
  bySet: flags.bySet,
15
+ catalog: flags.catalog,
15
16
  configName: flags.configName,
16
17
  count: flags.count,
17
18
  customerPath,
@@ -19,6 +19,16 @@
19
19
  "$ sk auto-prewarm customers/decathlon.de --workgroup prewarming"
20
20
  ],
21
21
  "flags": {
22
+ "catalog": {
23
+ "description": "Athena data catalog holding the rum tables. Names the catalog both in the query and in the context it runs in, so the two always agree",
24
+ "env": "SK_ATHENA_CATALOG",
25
+ "name": "catalog",
26
+ "required": false,
27
+ "default": "live",
28
+ "hasDynamicHelp": false,
29
+ "multiple": false,
30
+ "type": "option"
31
+ },
22
32
  "workgroup": {
23
33
  "description": "Athena workgroup to run the query in, e.g. 'prewarming'. Defaults to the account's default workgroup",
24
34
  "env": "SK_ATHENA_WORKGROUP",
@@ -1342,6 +1352,16 @@
1342
1352
  "$ sk query parameter customers/decathlon.de --explain"
1343
1353
  ],
1344
1354
  "flags": {
1355
+ "catalog": {
1356
+ "description": "Athena data catalog holding the rum tables. Names the catalog both in the query and in the context it runs in, so the two always agree",
1357
+ "env": "SK_ATHENA_CATALOG",
1358
+ "name": "catalog",
1359
+ "required": false,
1360
+ "default": "live",
1361
+ "hasDynamicHelp": false,
1362
+ "multiple": false,
1363
+ "type": "option"
1364
+ },
1345
1365
  "workgroup": {
1346
1366
  "description": "Athena workgroup to run the query in, e.g. 'prewarming'. Defaults to the account's default workgroup",
1347
1367
  "env": "SK_ATHENA_WORKGROUP",
@@ -1462,6 +1482,16 @@
1462
1482
  "$ sk query prewarm customers/decathlon.de --count"
1463
1483
  ],
1464
1484
  "flags": {
1485
+ "catalog": {
1486
+ "description": "Athena data catalog holding the rum tables. Names the catalog both in the query and in the context it runs in, so the two always agree",
1487
+ "env": "SK_ATHENA_CATALOG",
1488
+ "name": "catalog",
1489
+ "required": false,
1490
+ "default": "live",
1491
+ "hasDynamicHelp": false,
1492
+ "multiple": false,
1493
+ "type": "option"
1494
+ },
1465
1495
  "workgroup": {
1466
1496
  "description": "Athena workgroup to run the query in, e.g. 'prewarming'. Defaults to the account's default workgroup",
1467
1497
  "env": "SK_ATHENA_WORKGROUP",
@@ -1615,5 +1645,5 @@
1615
1645
  ]
1616
1646
  }
1617
1647
  },
1618
- "version": "4.27.1"
1648
+ "version": "4.27.2"
1619
1649
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@speedkit/cli",
3
3
  "description": "Speed Kit CLI",
4
- "version": "4.27.1",
4
+ "version": "4.27.2",
5
5
  "author": {
6
6
  "name": "Baqend.com",
7
7
  "email": "info@baqend.com"