dataiku-sdk 4.0.0 → 4.2.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.
Files changed (66) hide show
  1. package/README.md +1 -0
  2. package/dist/build-metadata.json +1 -1
  3. package/dist/packages/types/src/index.d.ts +5 -1
  4. package/dist/packages/types/src/index.js +5 -1
  5. package/dist/src/cli/command-syntax.json +4136 -2756
  6. package/dist/src/cli/commands/code-env.js +5 -3
  7. package/dist/src/cli/commands/dataset.js +6 -4
  8. package/dist/src/cli/commands/future.js +7 -2
  9. package/dist/src/cli/commands/index.js +4 -0
  10. package/dist/src/cli/commands/job.js +12 -9
  11. package/dist/src/cli/commands/messaging-channel.d.ts +17 -0
  12. package/dist/src/cli/commands/messaging-channel.js +385 -0
  13. package/dist/src/cli/commands/project-deployer.js +33 -0
  14. package/dist/src/cli/commands/project.js +30 -0
  15. package/dist/src/cli/commands/recipe.js +23 -2
  16. package/dist/src/cli/commands/scenario.d.ts +6 -0
  17. package/dist/src/cli/commands/scenario.js +495 -17
  18. package/dist/src/cli/contract.js +20 -4
  19. package/dist/src/cli/env.d.ts +6 -0
  20. package/dist/src/cli/env.js +8 -0
  21. package/dist/src/cli/flags.js +34 -0
  22. package/dist/src/cli/helpers/cleanup.js +11 -0
  23. package/dist/src/cli/list-shapes.js +5 -0
  24. package/dist/src/cli/output.d.ts +2 -0
  25. package/dist/src/cli/output.js +24 -4
  26. package/dist/src/cli/plans.js +94 -2
  27. package/dist/src/cli/read-shapes.js +92 -1
  28. package/dist/src/cli/result-shapes.d.ts +19 -1
  29. package/dist/src/cli/result-shapes.js +228 -4
  30. package/dist/src/cli/selector-suggestions.d.ts +14 -0
  31. package/dist/src/cli/selector-suggestions.js +56 -0
  32. package/dist/src/cli/usage.d.ts +1 -1
  33. package/dist/src/cli/usage.js +36 -4
  34. package/dist/src/cli/version.d.ts +2 -2
  35. package/dist/src/cli/version.js +2 -2
  36. package/dist/src/cli.js +62 -13
  37. package/dist/src/client.d.ts +16 -4
  38. package/dist/src/client.js +45 -9
  39. package/dist/src/errors.d.ts +2 -0
  40. package/dist/src/errors.js +12 -2
  41. package/dist/src/generated/action-output-schemas.json +296 -0
  42. package/dist/src/index.d.ts +1 -0
  43. package/dist/src/index.js +1 -0
  44. package/dist/src/resources/bundles.d.ts +18 -0
  45. package/dist/src/resources/bundles.js +26 -0
  46. package/dist/src/resources/futures.d.ts +3 -0
  47. package/dist/src/resources/futures.js +5 -1
  48. package/dist/src/resources/messaging-channels.d.ts +83 -0
  49. package/dist/src/resources/messaging-channels.js +108 -0
  50. package/dist/src/resources/projects.d.ts +24 -0
  51. package/dist/src/resources/projects.js +36 -0
  52. package/dist/src/resources/recipe-create.d.ts +4 -1
  53. package/dist/src/resources/recipe-create.js +11 -2
  54. package/dist/src/resources/scenario-authoring.d.ts +52 -0
  55. package/dist/src/resources/scenario-authoring.js +216 -0
  56. package/dist/src/resources/scenarios.d.ts +128 -11
  57. package/dist/src/resources/scenarios.js +273 -43
  58. package/dist/src/resources/sql.js +3 -3
  59. package/dist/src/utils/messaging-channel-secrets.d.ts +17 -0
  60. package/dist/src/utils/messaging-channel-secrets.js +114 -0
  61. package/package.json +2 -1
  62. package/plugin.json +1 -1
  63. package/skills/dataiku-dss/SKILL.md +15 -16
  64. package/skills/dataiku-dss/references/discovery.md +2 -2
  65. package/skills/dataiku-dss/references/notifications.md +29 -0
  66. package/skills/dataiku-dss/references/troubleshooting.md +1 -0
@@ -0,0 +1,83 @@
1
+ import { BaseResource } from "./base.js";
2
+ /** One entry of a channel's permission list: a group or a user, and whether it may use the channel. */
3
+ export interface MessagingChannelPermission extends Record<string, unknown> {
4
+ group?: string;
5
+ user?: string;
6
+ canUse?: boolean;
7
+ }
8
+ /** A channel as DSS lists or returns it. Configuration fields (credentials included) arrive unredacted. */
9
+ export interface MessagingChannel extends Record<string, unknown> {
10
+ id?: string;
11
+ type?: string;
12
+ family?: string;
13
+ }
14
+ export interface MessagingChannelListOptions {
15
+ /** Channel type to filter by, e.g. `smtp`, `aws-ses-mail`, `slack`. */
16
+ channelType?: string;
17
+ /** Channel family to filter by; `mail` selects every channel that sends email-like messages. */
18
+ channelFamily?: string;
19
+ }
20
+ export interface MessagingChannelCreateRequest {
21
+ type: string;
22
+ /** Channel id; DSS allocates one when omitted. */
23
+ id?: string;
24
+ /** Type-specific configuration (host, sender, webhookUrl, ...). */
25
+ configuration?: Record<string, unknown>;
26
+ /** Usage permissions; `null`/omitted lets DSS grant every user. */
27
+ permissions?: MessagingChannelPermission[] | null;
28
+ }
29
+ export interface MailAttachment {
30
+ /** Local file to attach. */
31
+ path: string;
32
+ /** Name the recipient sees; defaults to the file's base name. */
33
+ fileName?: string;
34
+ }
35
+ export interface MailMessageRequest {
36
+ to: string[];
37
+ subject: string;
38
+ body: string;
39
+ cc?: string[];
40
+ bcc?: string[];
41
+ /** Sender address; defaults to the sender configured on the channel. */
42
+ from?: string;
43
+ /** Send `body` as plain text instead of HTML. */
44
+ plainText?: boolean;
45
+ }
46
+ export interface MailSendOptions {
47
+ /** Project issuing the email; the caller needs "Write content" on it. Defaults to the client's project. */
48
+ projectKey?: string;
49
+ /** One message to every `to` address (true) or one message per address (false, DSS default). */
50
+ sendAsSingleMessage?: boolean;
51
+ attachments?: MailAttachment[];
52
+ }
53
+ /** The JSON `message` part of the send request, shaped like the official Python client's payload. */
54
+ export declare function mailMessagePayload(message: MailMessageRequest): Record<string, unknown>;
55
+ /** Query string of the send request. */
56
+ export declare function mailSendQuery(projectKey: string, sendAsSingleMessage: boolean | undefined): URLSearchParams;
57
+ /** The POST body of a channel creation, as the official Python client sends it. */
58
+ export declare function messagingChannelCreateBody(request: MessagingChannelCreateRequest): Record<string, unknown>;
59
+ export declare class MessagingChannelsResource extends BaseResource {
60
+ /** List messaging channels visible to the caller, optionally filtered by type or family. */
61
+ list(options?: MessagingChannelListOptions): Promise<MessagingChannel[]>;
62
+ /** Get one channel (type, family, and for mail channels the sender). */
63
+ get(channelId: string): Promise<MessagingChannel>;
64
+ /** Create a channel (admin). DSS returns the created channel. */
65
+ create(request: MessagingChannelCreateRequest): Promise<MessagingChannel>;
66
+ /** Get a channel's settings, i.e. its configuration (admin). */
67
+ getSettings(channelId: string): Promise<Record<string, unknown>>;
68
+ /** Replace a channel's settings (admin); pass the object obtained from {@link getSettings}. */
69
+ saveSettings(channelId: string, settings: Record<string, unknown>): Promise<void>;
70
+ /** Get the users and groups allowed to use a channel. */
71
+ getPermissions(channelId: string): Promise<MessagingChannelPermission[]>;
72
+ /** Replace a channel's permission list. */
73
+ setPermissions(channelId: string, permissions: MessagingChannelPermission[]): Promise<void>;
74
+ /** Delete a channel (admin). */
75
+ delete(channelId: string): Promise<void>;
76
+ /**
77
+ * Send an email through a mail-family channel, as a multipart request: a JSON
78
+ * `message` part plus one `attachments` part per file. This sends real mail
79
+ * and is never retried.
80
+ */
81
+ send(channelId: string, message: MailMessageRequest, options?: MailSendOptions): Promise<void>;
82
+ private channelPath;
83
+ }
@@ -0,0 +1,108 @@
1
+ import { readFile, } from "node:fs/promises";
2
+ import { basename, } from "node:path";
3
+ import { ClientValidationError, } from "../errors.js";
4
+ import { BaseResource, requireArrayResponse, requireNonEmpty, requireObject, } from "./base.js";
5
+ /** The JSON `message` part of the send request, shaped like the official Python client's payload. */
6
+ export function mailMessagePayload(message) {
7
+ return {
8
+ from: message.from ?? null,
9
+ to: message.to,
10
+ cc: message.cc ?? null,
11
+ bcc: message.bcc ?? null,
12
+ subject: message.subject,
13
+ body: message.body,
14
+ plainText: message.plainText ?? false,
15
+ };
16
+ }
17
+ /** Query string of the send request. */
18
+ export function mailSendQuery(projectKey, sendAsSingleMessage) {
19
+ return new URLSearchParams({
20
+ projectKey,
21
+ sendAsSingleMessage: String(sendAsSingleMessage ?? false),
22
+ });
23
+ }
24
+ /** The POST body of a channel creation, as the official Python client sends it. */
25
+ export function messagingChannelCreateBody(request) {
26
+ return {
27
+ id: request.id ? requireNonEmpty(request.id, "channelId") : "",
28
+ type: requireNonEmpty(request.type, "type"),
29
+ configuration: request.configuration === undefined
30
+ ? {}
31
+ : requireObject(request.configuration, "configuration"),
32
+ permissions: request.permissions ?? null,
33
+ };
34
+ }
35
+ export class MessagingChannelsResource extends BaseResource {
36
+ /** List messaging channels visible to the caller, optionally filtered by type or family. */
37
+ async list(options = {}) {
38
+ const query = new URLSearchParams();
39
+ if (options.channelType !== undefined)
40
+ query.set("channelType", options.channelType);
41
+ if (options.channelFamily !== undefined)
42
+ query.set("channelFamily", options.channelFamily);
43
+ const suffix = query.size > 0 ? `?${query.toString()}` : "";
44
+ const channels = await this.client.get(`/public/api/messaging-channels/${suffix}`);
45
+ return requireArrayResponse(channels, "messagingChannels.list");
46
+ }
47
+ /** Get one channel (type, family, and for mail channels the sender). */
48
+ async get(channelId) {
49
+ return this.client.get(this.channelPath(channelId));
50
+ }
51
+ /** Create a channel (admin). DSS returns the created channel. */
52
+ async create(request) {
53
+ return this.client.post("/public/api/messaging-channels/", messagingChannelCreateBody(request));
54
+ }
55
+ /** Get a channel's settings, i.e. its configuration (admin). */
56
+ async getSettings(channelId) {
57
+ return this.client.get(`${this.channelPath(channelId)}/settings`);
58
+ }
59
+ /** Replace a channel's settings (admin); pass the object obtained from {@link getSettings}. */
60
+ async saveSettings(channelId, settings) {
61
+ await this.client.putVoid(`${this.channelPath(channelId)}/settings`, requireObject(settings, "settings"));
62
+ }
63
+ /** Get the users and groups allowed to use a channel. */
64
+ async getPermissions(channelId) {
65
+ const permissions = await this.client.get(`${this.channelPath(channelId)}/permissions`);
66
+ return requireArrayResponse(permissions, "messagingChannels.getPermissions");
67
+ }
68
+ /** Replace a channel's permission list. */
69
+ async setPermissions(channelId, permissions) {
70
+ await this.client.putVoid(`${this.channelPath(channelId)}/permissions`, permissions);
71
+ }
72
+ /** Delete a channel (admin). */
73
+ async delete(channelId) {
74
+ await this.client.del(this.channelPath(channelId));
75
+ }
76
+ /**
77
+ * Send an email through a mail-family channel, as a multipart request: a JSON
78
+ * `message` part plus one `attachments` part per file. This sends real mail
79
+ * and is never retried.
80
+ */
81
+ async send(channelId, message, options = {}) {
82
+ const parts = [{
83
+ name: "message",
84
+ blob: new Blob([JSON.stringify(mailMessagePayload(message)),], {
85
+ type: "application/json",
86
+ }),
87
+ fileName: "send-payload.json",
88
+ },];
89
+ for (const attachment of options.attachments ?? []) {
90
+ let blob;
91
+ try {
92
+ blob = new Blob([await readFile(attachment.path),]);
93
+ }
94
+ catch {
95
+ throw new ClientValidationError(`Attachment could not be read: ${attachment.path}`, "validation_failed", "Verify that the path names an existing, readable file.", { path: attachment.path, });
96
+ }
97
+ parts.push({
98
+ name: "attachments",
99
+ blob,
100
+ fileName: attachment.fileName ?? basename(attachment.path),
101
+ });
102
+ }
103
+ await this.client.uploadFormVoid(`${this.channelPath(channelId)}/actions/send`, parts, mailSendQuery(this.resolveProjectKey(options.projectKey), options.sendAsSingleMessage));
104
+ }
105
+ channelPath(channelId) {
106
+ return `/public/api/messaging-channels/${encodeURIComponent(requireNonEmpty(channelId, "channelId"))}`;
107
+ }
108
+ }
@@ -87,6 +87,12 @@ export interface ProjectImportResult extends ProjectImportProcessResult {
87
87
  /** SHA-256 fingerprint of the landed project's creationTag. Present on verified success. */
88
88
  projectIncarnationHash?: string;
89
89
  }
90
+ export interface TestScenarioRunsReportOptions {
91
+ /** Automation-node bundle whose test runs are reported; the active bundle when omitted. */
92
+ bundleId?: string;
93
+ /** `junit` (default) is JUnit XML; `html` is a rendered page. */
94
+ format?: "junit" | "html";
95
+ }
90
96
  export declare class ProjectsResource extends BaseResource {
91
97
  /** Create a new project. */
92
98
  createProject(projectKey: string, name: string, ownerLogin: string, settings?: ProjectLifecycleSettings | null): Promise<Record<string, unknown>>;
@@ -139,6 +145,24 @@ export declare class ProjectsResource extends BaseResource {
139
145
  * covers branches, commits, pulls, and remotes.
140
146
  */
141
147
  pushToGitRemote(remote: string, projectKey?: string): Promise<void>;
148
+ /**
149
+ * Get the testing status of a project: the worst outcome across the last
150
+ * runs of its test scenarios. On an Automation node, `bundleId` restricts
151
+ * the runs to those made while that bundle was active.
152
+ */
153
+ getTestingStatus(projectKey?: string, bundleId?: string): Promise<Record<string, unknown>>;
154
+ /**
155
+ * Fetch the report of the latest test-scenario runs as text: JUnit XML by
156
+ * default, an HTML page for `format: "html"`. On an Automation node,
157
+ * `bundleId` selects the bundle; otherwise the active bundle is reported.
158
+ */
159
+ getTestScenarioRunsReport(projectKey?: string, options?: TestScenarioRunsReportOptions): Promise<string>;
160
+ /**
161
+ * Download the latest test-scenario runs report to a local file and return
162
+ * the byte count written. The file is only published once complete.
163
+ */
164
+ downloadTestScenarioRunsReport(filePath: string, projectKey?: string, options?: TestScenarioRunsReportOptions): Promise<number>;
165
+ private testScenarioRoute;
142
166
  /** Get project-level tags (tag name → {color?}). */
143
167
  tags(projectKey?: string): Promise<ProjectTags>;
144
168
  /**
@@ -102,6 +102,11 @@ const DEFAULT_MAX_NODES = 300;
102
102
  const DEFAULT_MAX_EDGES = 600;
103
103
  const DEFAULT_METADATA_TIMEOUT_MS = 1_500;
104
104
  const PROJECT_IMPORT_AMBIGUOUS_REMEDIATION = "Import processing left DSS state indeterminate. Inspect the archive with `dss project inspect-archive <file>` and the live projects with `dss project list` before retrying the import.";
105
+ function testReportRoute(options) {
106
+ return options.format === "html"
107
+ ? "last-test-scenario-runs-html-report"
108
+ : "last-test-scenario-runs-report";
109
+ }
105
110
  export class ProjectsResource extends BaseResource {
106
111
  /** Create a new project. */
107
112
  async createProject(projectKey, name, ownerLogin, settings) {
@@ -319,6 +324,37 @@ export class ProjectsResource extends BaseResource {
319
324
  async pushToGitRemote(remote, projectKey) {
320
325
  await this.client.post(`/public/api/projects/${this.enc(projectKey)}/actions/push-to-git-remote?remote=${encodeURIComponent(remote)}`);
321
326
  }
327
+ /**
328
+ * Get the testing status of a project: the worst outcome across the last
329
+ * runs of its test scenarios. On an Automation node, `bundleId` restricts
330
+ * the runs to those made while that bundle was active.
331
+ */
332
+ async getTestingStatus(projectKey, bundleId) {
333
+ return this.client.get(this.testScenarioRoute(projectKey, "testing-status", bundleId));
334
+ }
335
+ /**
336
+ * Fetch the report of the latest test-scenario runs as text: JUnit XML by
337
+ * default, an HTML page for `format: "html"`. On an Automation node,
338
+ * `bundleId` selects the bundle; otherwise the active bundle is reported.
339
+ */
340
+ async getTestScenarioRunsReport(projectKey, options = {}) {
341
+ return this.client.getText(this.testScenarioRoute(projectKey, testReportRoute(options), options.bundleId));
342
+ }
343
+ /**
344
+ * Download the latest test-scenario runs report to a local file and return
345
+ * the byte count written. The file is only published once complete.
346
+ */
347
+ async downloadTestScenarioRunsReport(filePath, projectKey, options = {}) {
348
+ const res = await this.client.stream(this.testScenarioRoute(projectKey, testReportRoute(options), options.bundleId));
349
+ if (!res.body) {
350
+ throw unexpectedResponseError("projects.downloadTestScenarioRunsReport response did not include a body");
351
+ }
352
+ return writeResponseToFile(filePath, res);
353
+ }
354
+ testScenarioRoute(projectKey, route, bundleId) {
355
+ const query = bundleId === undefined ? "" : `?bundleId=${encodeURIComponent(bundleId)}`;
356
+ return `/public/api/projects/${this.enc(projectKey)}/scenarios/${route}${query}`;
357
+ }
322
358
  /** Get project-level tags (tag name → {color?}). */
323
359
  async tags(projectKey) {
324
360
  const enc = this.enc(projectKey);
@@ -30,7 +30,10 @@ export declare function groupingPayloadConfig(opts: RecipeCreateOptions): Groupi
30
30
  /**
31
31
  * Apply grouping keys and aggregates to the payload DSS created: `keys` lists
32
32
  * the group columns; each aggregated column's `values[]` entry gets its
33
- * aggregate flags (and `orderColumn` for first/last).
33
+ * aggregate flags (and `orderColumn` for first/last). With aggregates given,
34
+ * DSS's per-group row count (`globalCount`, a `count` column) is turned off so
35
+ * the output holds exactly the keys and the requested aggregates; `COL:count`
36
+ * asks for a count. Keys alone keep the count as the only aggregate.
34
37
  */
35
38
  export declare function applyGroupingPayload(payload: Record<string, unknown>, config: GroupingPayloadConfig): Record<string, unknown>;
36
39
  /** Prepare option groups, in the default step order. */
@@ -164,7 +164,10 @@ export function groupingPayloadConfig(opts) {
164
164
  /**
165
165
  * Apply grouping keys and aggregates to the payload DSS created: `keys` lists
166
166
  * the group columns; each aggregated column's `values[]` entry gets its
167
- * aggregate flags (and `orderColumn` for first/last).
167
+ * aggregate flags (and `orderColumn` for first/last). With aggregates given,
168
+ * DSS's per-group row count (`globalCount`, a `count` column) is turned off so
169
+ * the output holds exactly the keys and the requested aggregates; `COL:count`
170
+ * asks for a count. Keys alone keep the count as the only aggregate.
168
171
  */
169
172
  export function applyGroupingPayload(payload, config) {
170
173
  const values = Array.isArray(payload.values)
@@ -182,7 +185,13 @@ export function applyGroupingPayload(payload, config) {
182
185
  entry.orderColumn = config.orderBy;
183
186
  }
184
187
  }
185
- return { ...payload, keys: config.keys.map((column) => ({ column, })), values, };
188
+ const hasAggregates = Object.keys(config.aggregates).length > 0;
189
+ return {
190
+ ...payload,
191
+ keys: config.keys.map((column) => ({ column, })),
192
+ values,
193
+ ...(hasAggregates ? { globalCount: false, } : {}),
194
+ };
186
195
  }
187
196
  function processorStep(type, params) {
188
197
  return {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Pure builders for the JSON DSS stores under `triggers` and `reporters` of a
3
+ * scenario. Trigger shapes mirror dataikuapi's `add_periodic_trigger`,
4
+ * `add_hourly_trigger`, `add_daily_trigger` (which becomes a Weekly trigger when
5
+ * given days) and `add_monthly_trigger`; the mail reporter shape is the one DSS
6
+ * 15 stores for a `mail-scenario` reporter.
7
+ */
8
+ export declare const SCENARIO_TRIGGER_FREQUENCIES: readonly ["minutely", "hourly", "daily", "weekly", "monthly"];
9
+ export type ScenarioTriggerFrequency = typeof SCENARIO_TRIGGER_FREQUENCIES[number];
10
+ export declare const SCENARIO_WEEKDAYS: readonly ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
11
+ export declare const SCENARIO_MONTHLY_RUN_ON: readonly ["ON_THE_DAY", "LAST_DAY_OF_THE_MONTH", "FIRST_WEEK", "SECOND_WEEK", "THIRD_WEEK", "FOURTH_WEEK", "LAST_WEEK"];
12
+ export declare const SCENARIO_REPORTER_PHASES: readonly ["START", "END"];
13
+ export interface TemporalTriggerSpec {
14
+ frequency: ScenarioTriggerFrequency;
15
+ /** Repeat interval: minutes (minutely), hours, days, weeks or months. */
16
+ every?: number;
17
+ /** Hour of the day (daily, weekly, monthly) or hour the schedule starts from (hourly). */
18
+ hour?: number;
19
+ minute?: number;
20
+ /** Weekday names; required by `weekly`, rejected elsewhere. */
21
+ days?: readonly string[];
22
+ /** Day of the month of the start date (monthly, default 1). */
23
+ dayOfMonth?: number;
24
+ runOn?: string;
25
+ /** `YYYY-MM-DD` day before which the trigger does not run; default today (monthly: January 1st of this year). */
26
+ startDate?: string;
27
+ timezone?: string;
28
+ name?: string;
29
+ active?: boolean;
30
+ }
31
+ /** The scenario trigger object dataikuapi would append for this schedule. */
32
+ export declare function temporalTrigger(spec: TemporalTriggerSpec, now?: Date): Record<string, unknown>;
33
+ export interface MailReporterSpec {
34
+ channel: string;
35
+ recipient: string;
36
+ subject: string;
37
+ message: string;
38
+ /** DSS expression gating the report, e.g. `outcome == 'FAILED'`. */
39
+ condition?: string;
40
+ phase?: string;
41
+ name?: string;
42
+ active?: boolean;
43
+ }
44
+ /** A `mail-scenario` reporter as DSS 15 stores it (inline message, Freemarker, no attachments). */
45
+ export declare function mailReporter(spec: MailReporterSpec): Record<string, unknown>;
46
+ /** Which element of a trigger/reporter list to remove. */
47
+ export interface ScenarioListSelector {
48
+ index?: number;
49
+ name?: string;
50
+ }
51
+ /** Position of the selected element; ambiguous or missing selections throw. */
52
+ export declare function selectScenarioListIndex(items: readonly unknown[], selector: ScenarioListSelector, kind: "trigger" | "reporter"): number;
@@ -0,0 +1,216 @@
1
+ import { ClientValidationError, } from "../errors.js";
2
+ import { isRecord, } from "../utils/records.js";
3
+ /**
4
+ * Pure builders for the JSON DSS stores under `triggers` and `reporters` of a
5
+ * scenario. Trigger shapes mirror dataikuapi's `add_periodic_trigger`,
6
+ * `add_hourly_trigger`, `add_daily_trigger` (which becomes a Weekly trigger when
7
+ * given days) and `add_monthly_trigger`; the mail reporter shape is the one DSS
8
+ * 15 stores for a `mail-scenario` reporter.
9
+ */
10
+ export const SCENARIO_TRIGGER_FREQUENCIES = [
11
+ "minutely",
12
+ "hourly",
13
+ "daily",
14
+ "weekly",
15
+ "monthly",
16
+ ];
17
+ export const SCENARIO_WEEKDAYS = [
18
+ "Monday",
19
+ "Tuesday",
20
+ "Wednesday",
21
+ "Thursday",
22
+ "Friday",
23
+ "Saturday",
24
+ "Sunday",
25
+ ];
26
+ export const SCENARIO_MONTHLY_RUN_ON = [
27
+ "ON_THE_DAY",
28
+ "LAST_DAY_OF_THE_MONTH",
29
+ "FIRST_WEEK",
30
+ "SECOND_WEEK",
31
+ "THIRD_WEEK",
32
+ "FOURTH_WEEK",
33
+ "LAST_WEEK",
34
+ ];
35
+ export const SCENARIO_REPORTER_PHASES = ["START", "END",];
36
+ function invalid(message) {
37
+ return new ClientValidationError(message, "invalid_flag_value");
38
+ }
39
+ /** Membership test that narrows a free-form string to one of the allowed literals. */
40
+ function isOneOf(allowed, value) {
41
+ return allowed.includes(value);
42
+ }
43
+ function boundedInteger(value, name, min, max) {
44
+ if (!Number.isInteger(value) || value < min || value > max) {
45
+ throw invalid(`${name} must be an integer from ${min} to ${max}, got ${value}.`);
46
+ }
47
+ return value;
48
+ }
49
+ const pad = (value, width = 2) => String(value).padStart(width, "0");
50
+ /**
51
+ * dataikuapi's `startingFrom`: local midnight of the date, with milliseconds and
52
+ * a `+HHMM` offset (taken from the machine's time zone, as dataikuapi does).
53
+ */
54
+ function startingFrom(year, month, day) {
55
+ const local = new Date(year, month - 1, day, 0, 0, 0, 0);
56
+ if (local.getFullYear() !== year || local.getMonth() !== month - 1 || local.getDate() !== day) {
57
+ throw invalid(`${year}-${pad(month)}-${pad(day)} is not a calendar date.`);
58
+ }
59
+ const offsetMinutes = -local.getTimezoneOffset();
60
+ const sign = offsetMinutes < 0 ? "-" : "+";
61
+ const absolute = Math.abs(offsetMinutes);
62
+ return `${year}-${pad(month)}-${pad(day)}T00:00:00.000${sign}${pad(Math.floor(absolute / 60))}${pad(absolute % 60)}`;
63
+ }
64
+ function startingFromDate(startDate, fallback) {
65
+ if (startDate === undefined)
66
+ return fallback();
67
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(startDate);
68
+ if (!match)
69
+ throw invalid(`--start-date must be YYYY-MM-DD, got ${JSON.stringify(startDate)}.`);
70
+ return startingFrom(Number(match[1]), Number(match[2]), Number(match[3]));
71
+ }
72
+ /** The scenario trigger object dataikuapi would append for this schedule. */
73
+ export function temporalTrigger(spec, now = new Date()) {
74
+ const every = spec.every === undefined
75
+ ? undefined
76
+ : boundedInteger(spec.every, "--every", 1, 100_000);
77
+ const timezone = spec.timezone ?? "SERVER";
78
+ if (spec.frequency !== "weekly" && spec.days !== undefined) {
79
+ throw invalid(`--days applies to weekly triggers only (got --frequency ${spec.frequency}).`);
80
+ }
81
+ if (spec.frequency !== "monthly" && (spec.runOn !== undefined || spec.dayOfMonth !== undefined)) {
82
+ throw invalid(`--run-on and --day-of-month apply to monthly triggers only (got --frequency ${spec.frequency}).`);
83
+ }
84
+ if (spec.frequency === "minutely") {
85
+ for (const [flag, value,] of [["--hour", spec.hour,], ["--minute", spec.minute,], [
86
+ "--start-date",
87
+ spec.startDate,
88
+ ], ["--timezone", spec.timezone,],]) {
89
+ if (value !== undefined)
90
+ throw invalid(`${flag} does not apply to minutely triggers.`);
91
+ }
92
+ }
93
+ const today = () => startingFrom(now.getFullYear(), now.getMonth() + 1, now.getDate());
94
+ const trigger = (params) => ({
95
+ ...(spec.name === undefined ? {} : { name: spec.name, }),
96
+ active: spec.active ?? true,
97
+ type: "temporal",
98
+ params,
99
+ });
100
+ if (spec.frequency === "minutely") {
101
+ return trigger({ frequency: "Minutely", repeatFrequency: every ?? 5, });
102
+ }
103
+ const minute = boundedInteger(spec.minute ?? 0, "--minute", 0, 59);
104
+ if (spec.frequency === "hourly") {
105
+ return trigger({
106
+ frequency: "Hourly",
107
+ hour: boundedInteger(spec.hour ?? 0, "--hour", 0, 23),
108
+ minute,
109
+ startingFrom: startingFromDate(spec.startDate, today),
110
+ repeatFrequency: every ?? 1,
111
+ timezone,
112
+ });
113
+ }
114
+ const hour = boundedInteger(spec.hour ?? 2, "--hour", 0, 23);
115
+ if (spec.frequency === "daily") {
116
+ return trigger({
117
+ frequency: "Daily",
118
+ hour,
119
+ minute,
120
+ startingFrom: startingFromDate(spec.startDate, today),
121
+ repeatFrequency: every ?? 1,
122
+ timezone,
123
+ });
124
+ }
125
+ if (spec.frequency === "weekly") {
126
+ if (spec.days === undefined || spec.days.length === 0) {
127
+ throw invalid(`--days is required for weekly triggers (${SCENARIO_WEEKDAYS.join(", ")}, comma-separated).`);
128
+ }
129
+ const unknown = spec.days.filter((day) => !isOneOf(SCENARIO_WEEKDAYS, day));
130
+ if (unknown.length > 0) {
131
+ throw invalid(`Unknown weekday ${unknown.map((day) => JSON.stringify(day)).join(", ")}; use ${SCENARIO_WEEKDAYS.join(", ")}.`);
132
+ }
133
+ return trigger({
134
+ frequency: "Weekly",
135
+ hour,
136
+ minute,
137
+ startingFrom: startingFromDate(spec.startDate, today),
138
+ daysOfWeek: [...spec.days,],
139
+ repeatFrequency: every ?? 1,
140
+ timezone,
141
+ });
142
+ }
143
+ const runOn = spec.runOn ?? "ON_THE_DAY";
144
+ if (!isOneOf(SCENARIO_MONTHLY_RUN_ON, runOn)) {
145
+ throw invalid(`--run-on must be one of ${SCENARIO_MONTHLY_RUN_ON.join(", ")}, got ${runOn}.`);
146
+ }
147
+ if (spec.startDate !== undefined && spec.dayOfMonth !== undefined) {
148
+ throw invalid("--start-date and --day-of-month are mutually exclusive for monthly triggers.");
149
+ }
150
+ const dayOfMonth = boundedInteger(spec.dayOfMonth ?? 1, "--day-of-month", 1, 31);
151
+ return trigger({
152
+ frequency: "Monthly",
153
+ startingFrom: startingFromDate(spec.startDate,
154
+ // dataikuapi starts monthly schedules on January 1st (not today) so that day 31 never lands on a short month.
155
+ () => startingFrom(now.getFullYear(), 1, dayOfMonth)),
156
+ hour,
157
+ minute,
158
+ monthlyRunOn: runOn,
159
+ repeatFrequency: every ?? 1,
160
+ timezone,
161
+ });
162
+ }
163
+ function requiredText(value, name) {
164
+ if (typeof value !== "string" || value.trim().length === 0) {
165
+ throw invalid(`${name} must be a non-empty string.`);
166
+ }
167
+ return value;
168
+ }
169
+ /** A `mail-scenario` reporter as DSS 15 stores it (inline message, Freemarker, no attachments). */
170
+ export function mailReporter(spec) {
171
+ const phase = spec.phase ?? "END";
172
+ if (!isOneOf(SCENARIO_REPORTER_PHASES, phase)) {
173
+ throw invalid(`--phase must be ${SCENARIO_REPORTER_PHASES.join(" or ")}, got ${phase}.`);
174
+ }
175
+ if (spec.condition !== undefined)
176
+ requiredText(spec.condition, "--condition");
177
+ return {
178
+ ...(spec.name === undefined ? {} : { name: spec.name, }),
179
+ active: spec.active ?? true,
180
+ phase,
181
+ runConditionEnabled: spec.condition !== undefined,
182
+ runCondition: spec.condition ?? "true",
183
+ messaging: {
184
+ type: "mail-scenario",
185
+ configuration: {
186
+ channelId: requiredText(spec.channel, "--channel"),
187
+ recipient: requiredText(spec.recipient, "--recipient"),
188
+ subject: spec.subject,
189
+ message: spec.message,
190
+ messageSource: "INLINE",
191
+ templateFormat: "FREEMARKER",
192
+ sendAsHTML: false,
193
+ attachments: [],
194
+ },
195
+ },
196
+ };
197
+ }
198
+ /** Position of the selected element; ambiguous or missing selections throw. */
199
+ export function selectScenarioListIndex(items, selector, kind) {
200
+ if ((selector.index === undefined) === (selector.name === undefined)) {
201
+ throw invalid(`Pass exactly one of --index or --name to select the ${kind}.`);
202
+ }
203
+ if (selector.index !== undefined) {
204
+ if (!Number.isInteger(selector.index) || selector.index < 0 || selector.index >= items.length) {
205
+ throw invalid(`--index ${selector.index} is outside the ${items.length} ${kind}(s) of this scenario (0-based).`);
206
+ }
207
+ return selector.index;
208
+ }
209
+ const matches = items.flatMap((item, index) => isRecord(item) && item["name"] === selector.name ? [index,] : []);
210
+ if (matches.length !== 1) {
211
+ throw invalid(matches.length === 0
212
+ ? `No ${kind} is named ${JSON.stringify(selector.name)}.`
213
+ : `${matches.length} ${kind}s are named ${JSON.stringify(selector.name)} (indexes ${matches.join(", ")}); use --index.`);
214
+ }
215
+ return matches[0];
216
+ }