@speedkit/cli 4.27.0 → 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 (42) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +46 -14
  3. package/dist/commands/auto-prewarm.d.ts +8 -3
  4. package/dist/commands/auto-prewarm.js +16 -7
  5. package/dist/commands/query/parameter.d.ts +2 -0
  6. package/dist/commands/query/parameter.js +2 -1
  7. package/dist/commands/query/prewarm.d.ts +2 -0
  8. package/dist/commands/query/prewarm.js +2 -1
  9. package/dist/helpers/clipboard.d.ts +6 -0
  10. package/dist/helpers/clipboard.js +7 -1
  11. package/dist/models/cli-parameters.d.ts +40 -1
  12. package/dist/models/cli-parameters.js +51 -7
  13. package/dist/services/athena/athena-service-factory.d.ts +8 -1
  14. package/dist/services/athena/athena-service-factory.js +15 -4
  15. package/dist/services/athena/athena-service-model.d.ts +17 -0
  16. package/dist/services/athena/athena-service-model.js +19 -0
  17. package/dist/services/athena/athena-service.d.ts +21 -1
  18. package/dist/services/athena/athena-service.js +21 -3
  19. package/dist/services/athena/athena-service.spec.js +36 -1
  20. package/dist/services/athena/baqend-athena-service.d.ts +13 -4
  21. package/dist/services/athena/baqend-athena-service.js +25 -10
  22. package/dist/services/athena/baqend-athena-service.spec.d.ts +1 -0
  23. package/dist/services/athena/baqend-athena-service.spec.js +51 -0
  24. package/dist/services/cli/cli-service.d.ts +9 -0
  25. package/dist/services/cli/cli-service.js +57 -3
  26. package/dist/services/cli/cli-service.spec.d.ts +1 -0
  27. package/dist/services/cli/cli-service.spec.js +67 -0
  28. package/dist/services/prewarm/auto-pre-warm-factory.d.ts +10 -0
  29. package/dist/services/prewarm/auto-pre-warm-factory.js +5 -0
  30. package/dist/services/query-builder/queries/page-source.d.ts +11 -2
  31. package/dist/services/query-builder/queries/page-source.js +15 -3
  32. package/dist/services/query-builder/queries/query-model.d.ts +2 -0
  33. package/dist/services/query-builder/queries/spec/queries.spec.js +17 -0
  34. package/dist/services/query-builder/query-builder-factory.js +2 -1
  35. package/dist/services/query-builder/query-builder-model.d.ts +6 -0
  36. package/dist/services/query-builder/query-builder-service.js +12 -7
  37. package/dist/services/query-builder/query-builder-service.spec.d.ts +1 -0
  38. package/dist/services/query-builder/query-builder-service.spec.js +65 -0
  39. package/dist/services/query-builder/query-command.d.ts +2 -0
  40. package/dist/services/query-builder/query-command.js +2 -0
  41. package/oclif.manifest.json +100 -19
  42. package/package.json +1 -1
@@ -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
  };
@@ -10,7 +10,27 @@ import { CliServiceInterface } from "../cli/index.js";
10
10
  export declare class AwsAthenaService implements AthenaService {
11
11
  private client;
12
12
  private cli;
13
- constructor(client: AthenaClient, cli: CliServiceInterface);
13
+ /**
14
+ * Athena falls back to the account's default workgroup when this is unset,
15
+ * which is only right where the credentials are allowed to use it.
16
+ */
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?;
23
+ constructor(client: AthenaClient, cli: CliServiceInterface,
24
+ /**
25
+ * Athena falls back to the account's default workgroup when this is unset,
26
+ * which is only right where the credentials are allowed to use it.
27
+ */
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);
14
34
  getResult<T>(query: string, parameters?: string[], maxAgeInMinutes?: number): Promise<T[]>;
15
35
  /** The query, from sending it off to the rows it produced. */
16
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
  /**
@@ -11,9 +11,23 @@ import { safe } from "../../helpers/safe.js";
11
11
  export class AwsAthenaService {
12
12
  client;
13
13
  cli;
14
- constructor(client, cli) {
14
+ workgroup;
15
+ catalog;
16
+ constructor(client, cli,
17
+ /**
18
+ * Athena falls back to the account's default workgroup when this is unset,
19
+ * which is only right where the credentials are allowed to use it.
20
+ */
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) {
15
27
  this.client = client;
16
28
  this.cli = cli;
29
+ this.workgroup = workgroup;
30
+ this.catalog = catalog;
17
31
  }
18
32
  async getResult(query, parameters = [], maxAgeInMinutes = DEFAULT_MAX_AGE_IN_MINUTES) {
19
33
  this.cli.startAction("ATHENA:QUERY", "execute athena query");
@@ -99,7 +113,7 @@ export class AwsAthenaService {
99
113
  const command = new StartQueryExecutionCommand({
100
114
  ExecutionParameters: executionParameters.length > 0 ? executionParameters : null,
101
115
  QueryExecutionContext: {
102
- Catalog: "live",
116
+ Catalog: athenaCatalog(this.catalog),
103
117
  },
104
118
  QueryString: sql,
105
119
  ResultReuseConfiguration: {
@@ -108,6 +122,10 @@ export class AwsAthenaService {
108
122
  MaxAgeInMinutes: maxAgeInMinutes,
109
123
  },
110
124
  },
125
+ // Omitted rather than sent empty: Athena reads an absent WorkGroup as
126
+ // "the default one", and an empty string as a workgroup that does not
127
+ // exist.
128
+ ...(this.workgroup ? { WorkGroup: this.workgroup } : {}),
111
129
  });
112
130
  const response = await this.client.send(command);
113
131
  await this.waitOnResponse(response.QueryExecutionId);
@@ -14,9 +14,10 @@ function recordingCli() {
14
14
  return { calls, cli };
15
15
  }
16
16
  /** An Athena that answers every execution with the given state. */
17
- function clientAnswering(state, reason) {
17
+ function clientAnswering(state, reason, sent = []) {
18
18
  return {
19
19
  send: async (command) => {
20
+ sent.push(command);
20
21
  if (command instanceof StartQueryExecutionCommand) {
21
22
  return { QueryExecutionId: "query-1" };
22
23
  }
@@ -62,6 +63,40 @@ describe("AwsAthenaService", () => {
62
63
  expect(result.message).to.contain("INVALID_ARGUMENTS: nope");
63
64
  expect(calls).to.deep.equal(["start:ATHENA:QUERY", "fail:ATHENA:QUERY"]);
64
65
  });
66
+ it("should run in the workgroup it was given", async () => {
67
+ const { cli } = recordingCli();
68
+ const sent = [];
69
+ const service = new AwsAthenaService(clientAnswering("SUCCEEDED", undefined, sent), cli, "prewarming");
70
+ await service.getResult("select 1", [], 0);
71
+ const start = sent.find((command) => command instanceof StartQueryExecutionCommand);
72
+ expect(start.input.WorkGroup).to.equal("prewarming");
73
+ });
74
+ it("should leave the workgroup out when none was given", async () => {
75
+ // Athena reads an absent WorkGroup as the account default; an empty string
76
+ // is a workgroup that does not exist, so the key has to be gone entirely.
77
+ const { cli } = recordingCli();
78
+ const sent = [];
79
+ const service = new AwsAthenaService(clientAnswering("SUCCEEDED", undefined, sent), cli);
80
+ await service.getResult("select 1", [], 0);
81
+ const start = sent.find((command) => command instanceof StartQueryExecutionCommand);
82
+ expect(start.input).to.not.have.property("WorkGroup");
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
+ });
65
100
  it("should not pass a failed query off as an empty result", async () => {
66
101
  const { cli } = recordingCli();
67
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
+ });
@@ -6,8 +6,18 @@ export interface AutoPreWarmContext extends QueryFilterOptions {
6
6
  readonly configName: string;
7
7
  /** Device classes to warm each url in the variations it was requested in. */
8
8
  readonly device?: readonly string[];
9
+ /** Drop urls the origin mostly answers with a 404. */
10
+ readonly excludeNotFound?: boolean;
11
+ /** Keep only the most requested urls. */
12
+ readonly limit?: number;
9
13
  /** Regular expressions the pre-warmed urls have to match. */
10
14
  readonly match?: readonly string[];
15
+ /** Keep only urls requested at least this often in the window. */
16
+ readonly minHits?: number;
17
+ /** Athena workgroup the query runs in. */
18
+ readonly workgroup?: string;
19
+ /** Athena data catalog the rum tables live in. */
20
+ readonly catalog?: string;
11
21
  /** Variations to pair every url with, when no traffic can reveal them. */
12
22
  readonly variation?: readonly string[];
13
23
  readonly variationPath?: string;
@@ -34,13 +34,18 @@ export class AutoPreWarmFactory {
34
34
  days: this.context.days,
35
35
  devices: normalizeDevices(this.context.device),
36
36
  excludeNoindex: this.context.excludeNoindex,
37
+ excludeNotFound: this.context.excludeNotFound,
37
38
  excludeSuspicious: this.context.excludeSuspicious,
38
39
  execute: true,
39
40
  hardNavigationsOnly: this.context.hardNavigationsOnly,
40
41
  isWindows: this.context.isWindows,
42
+ limit: this.context.limit,
41
43
  match: this.context.match,
44
+ minHits: this.context.minHits,
42
45
  quiet: this.context.quiet,
43
46
  variations: this.context.variation,
47
+ workgroup: this.context.workgroup,
48
+ catalog: this.context.catalog,
44
49
  }, this.userConfig).buildService(QueryType.prewarm);
45
50
  const app = queryService.app;
46
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).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
  }