dataiku-sdk 3.7.0 → 3.8.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 (47) hide show
  1. package/dist/build-metadata.json +1 -1
  2. package/dist/packages/types/src/index.d.ts +40 -4
  3. package/dist/packages/types/src/index.js +22 -1
  4. package/dist/src/cli/coerce.d.ts +1 -0
  5. package/dist/src/cli/coerce.js +1 -1
  6. package/dist/src/cli/command-syntax.json +120 -0
  7. package/dist/src/cli/commands/dataset.js +42 -6
  8. package/dist/src/cli/commands/flow-zone.js +241 -46
  9. package/dist/src/cli/commands/job.js +18 -3
  10. package/dist/src/cli/commands/recipe.d.ts +4 -0
  11. package/dist/src/cli/commands/recipe.js +38 -1
  12. package/dist/src/cli/commands/wiki.js +56 -0
  13. package/dist/src/cli/contract.js +11 -3
  14. package/dist/src/cli/doctor.js +2 -0
  15. package/dist/src/cli/flags.d.ts +7 -0
  16. package/dist/src/cli/flags.js +28 -2
  17. package/dist/src/cli/helpers/dataset.d.ts +12 -0
  18. package/dist/src/cli/helpers/dataset.js +36 -0
  19. package/dist/src/cli/helpers/flow-zone.d.ts +21 -0
  20. package/dist/src/cli/helpers/flow-zone.js +144 -4
  21. package/dist/src/cli/output.js +6 -0
  22. package/dist/src/cli/plans.js +58 -2
  23. package/dist/src/cli/types.d.ts +2 -0
  24. package/dist/src/cli.js +12 -4
  25. package/dist/src/errors.d.ts +11 -1
  26. package/dist/src/errors.js +18 -3
  27. package/dist/src/resources/dataset-create.d.ts +19 -0
  28. package/dist/src/resources/dataset-create.js +69 -0
  29. package/dist/src/resources/datasets.d.ts +21 -0
  30. package/dist/src/resources/datasets.js +61 -1
  31. package/dist/src/resources/flow-zones.d.ts +29 -3
  32. package/dist/src/resources/flow-zones.js +53 -3
  33. package/dist/src/resources/jobs.d.ts +34 -0
  34. package/dist/src/resources/jobs.js +68 -1
  35. package/dist/src/resources/projects.d.ts +9 -0
  36. package/dist/src/resources/projects.js +10 -3
  37. package/dist/src/resources/recipe-create.d.ts +17 -3
  38. package/dist/src/resources/recipe-create.js +149 -33
  39. package/dist/src/resources/recipes.js +76 -23
  40. package/dist/src/resources/sql.js +52 -13
  41. package/dist/src/resources/wiki.d.ts +47 -1
  42. package/dist/src/resources/wiki.js +89 -0
  43. package/dist/src/utils/flow-analysis.js +4 -0
  44. package/package.json +1 -1
  45. package/plugin.json +1 -1
  46. package/skills/dataiku-dss/references/flow-maps.md +2 -2
  47. package/skills/dataiku-dss/references/troubleshooting.md +1 -1
@@ -9,6 +9,60 @@ export function assertDatasetTypeCreatable(dsType) {
9
9
  return;
10
10
  throw new ClientValidationError("Inline (editable) datasets cannot be created here: the DSS public API cannot write their rows, and an Inline dataset without rows fails every downstream build (SourceDatasetNotReadyException). For a small static table, create an UploadedFiles dataset and upload a CSV with dss dataset upload-file, or write the rows from a python recipe into a managed dataset.", "validation_failed");
11
11
  }
12
+ /**
13
+ * Dataset types that read from a SQL connection. These are the types DSS
14
+ * detects schemas for through `testAndDetectSettings/externalSQL` (the list
15
+ * dataikuapi's `DSSDataset.autodetect_settings` uses).
16
+ */
17
+ export const SQL_DATASET_TYPES = [
18
+ "JDBC",
19
+ "PostgreSQL",
20
+ "MySQL",
21
+ "Vertica",
22
+ "Snowflake",
23
+ "Redshift",
24
+ "Greenplum",
25
+ "Teradata",
26
+ "Oracle",
27
+ "SQLServer",
28
+ "SAPHANA",
29
+ "Netezza",
30
+ "BigQuery",
31
+ "Athena",
32
+ "hiveserver2",
33
+ "Synapse",
34
+ "FabricWarehouse",
35
+ "Databricks",
36
+ "DatabricksLakebase",
37
+ ];
38
+ export function isSqlDatasetType(dsType) {
39
+ const lower = dsType.toLowerCase();
40
+ return SQL_DATASET_TYPES.some((type) => type.toLowerCase() === lower);
41
+ }
42
+ /**
43
+ * Validate the SQL source options (`table`/`dbSchema`/`catalog`/`query`) of a
44
+ * dataset creation against its type. Shared by `dataset create`, its plan, and
45
+ * the SDK so every entry point rejects the same combinations.
46
+ */
47
+ export function assertSqlDatasetSource(dsType, source) {
48
+ const hasTable = source.table !== undefined;
49
+ const hasQuery = source.query !== undefined;
50
+ if (hasTable && hasQuery) {
51
+ throw new ClientValidationError("Pass either a table or a query for a SQL dataset, not both.", "conflicting_input_sources");
52
+ }
53
+ if (!hasTable && (source.dbSchema !== undefined || source.catalog !== undefined)) {
54
+ throw new ClientValidationError("A schema or catalog only applies together with a table.", "validation_failed", "Add --table, or drop --schema/--catalog (a query names its own tables).");
55
+ }
56
+ if (hasTable && source.table.trim() === "") {
57
+ throw new ClientValidationError("table must be a non-empty table name.", "invalid_flag_value");
58
+ }
59
+ if (hasQuery && source.query.trim() === "") {
60
+ throw new ClientValidationError("query must be a non-empty SQL query.", "invalid_flag_value");
61
+ }
62
+ if ((hasTable || hasQuery) && !isSqlDatasetType(dsType)) {
63
+ throw new ClientValidationError(`A table or query only applies to SQL dataset types, not "${dsType}".`, "validation_failed", `SQL types: ${SQL_DATASET_TYPES.join(", ")}.`);
64
+ }
65
+ }
12
66
  /** Wire body for POST /datasets/; shared with `dataset create --plan` so the plan matches the request. */
13
67
  export function buildDatasetCreateBody(opts) {
14
68
  assertDatasetTypeCreatable(opts.dsType);
@@ -23,6 +77,21 @@ export function buildDatasetCreateBody(opts) {
23
77
  if (!opts.connection) {
24
78
  throw new ClientValidationError("connection is required unless dsType is UploadedFiles.");
25
79
  }
80
+ if (opts.query !== undefined) {
81
+ if (opts.table !== undefined) {
82
+ throw new ClientValidationError("Pass either a table or a query for a SQL dataset, not both.", "conflicting_input_sources");
83
+ }
84
+ if (opts.query.trim() === "") {
85
+ throw new ClientValidationError("query must be a non-empty SQL query.", "invalid_flag_value");
86
+ }
87
+ return {
88
+ projectKey: opts.projectKey,
89
+ name: opts.datasetName,
90
+ type: opts.dsType,
91
+ params: { connection: opts.connection, mode: "query", query: opts.query, },
92
+ managed: opts.managed ?? false,
93
+ };
94
+ }
26
95
  if (opts.table) {
27
96
  const params = {
28
97
  connection: opts.connection,
@@ -55,6 +55,18 @@ export interface DatasetManagedCreateResult {
55
55
  projectKey: string;
56
56
  creationSettings: Record<string, unknown>;
57
57
  }
58
+ /** Outcome of {@link DatasetsResource.detectSqlSchema}. */
59
+ export interface DatasetSqlSchemaDetection {
60
+ /** True when DSS suggested a schema and it was stored on the dataset. */
61
+ detected: boolean;
62
+ /** Stored columns (empty when detection failed). */
63
+ columns: Array<{
64
+ name: string;
65
+ type: string;
66
+ }>;
67
+ /** Why detection failed. */
68
+ error?: string;
69
+ }
58
70
  export interface DatasetCloneOptions {
59
71
  projectKey?: string;
60
72
  path?: string;
@@ -106,6 +118,15 @@ export declare class DatasetsResource extends BaseResource {
106
118
  schema(datasetName: string, projectKey?: string): Promise<DatasetSchema>;
107
119
  /** Replace dataset schema columns directly through the schema endpoint. */
108
120
  updateSchema(datasetName: string, columns: DatasetSchemaColumnInput[], projectKey?: string): Promise<void>;
121
+ /**
122
+ * Detect the schema of a SQL dataset (table or query mode) with DSS and
123
+ * store it: `POST /actions/testAndDetectSettings/externalSQL` (what
124
+ * dataikuapi's `autodetect_settings` calls), then `PUT /schema` with the
125
+ * suggested schema. DSS reports connection, table, and query problems in the
126
+ * response body (HTTP 200), so a failed detection is returned with
127
+ * `detected: false` and the reason instead of thrown.
128
+ */
129
+ detectSqlSchema(datasetName: string, projectKey?: string): Promise<DatasetSqlSchemaDetection>;
109
130
  /**
110
131
  * Get the raw dataset schema object exactly as the DSS schema endpoint
111
132
  * returns it, without schema validation — used for deterministic comparison.
@@ -4,6 +4,7 @@ import { StringDecoder, } from "node:string_decoder";
4
4
  import { ClientValidationError, DataikuError, } from "../errors.js";
5
5
  import { DatasetDetailsSchema, DatasetSchemaSchema, DatasetSummaryArraySchema, } from "../schemas.js";
6
6
  import { deepMerge, } from "../utils/deep-merge.js";
7
+ import { isRecord, } from "../utils/records.js";
7
8
  import { sanitizeFileName, } from "../utils/sanitize.js";
8
9
  import { BaseResource, } from "./base.js";
9
10
  import { resolveAdminManagedStorageConnection, } from "./connections.js";
@@ -519,6 +520,28 @@ function cloneDatasetParams(params) {
519
520
  }
520
521
  return cloned;
521
522
  }
523
+ /** Explain an empty detection result from the first error DSS put in the response body. */
524
+ function describeSqlDetectionFailure(raw) {
525
+ if (!isRecord(raw))
526
+ return "DSS returned no schema detection result.";
527
+ for (const [key, value,] of Object.entries(raw)) {
528
+ if (!key.endsWith("Error"))
529
+ continue;
530
+ if (typeof value === "string" && value !== "")
531
+ return value;
532
+ if (isRecord(value)) {
533
+ const message = value["detailedMessage"] ?? value["message"];
534
+ if (typeof message === "string" && message !== "")
535
+ return message;
536
+ }
537
+ }
538
+ const failedChecks = Object.entries(raw)
539
+ .filter(([key, value,]) => key.endsWith("OK") && value === false)
540
+ .map(([key,]) => key);
541
+ return failedChecks.length > 0
542
+ ? `DSS detected no columns (failed checks: ${failedChecks.join(", ")}).`
543
+ : "DSS detected no columns.";
544
+ }
522
545
  function parseUploadedFiles(raw, datasetName) {
523
546
  if (!Array.isArray(raw)) {
524
547
  throw new ClientValidationError(`DSS returned an invalid uploaded-files list for dataset ${datasetName}.`, "validation_failed", "Expected an array from the UploadedFiles dataset file endpoint.");
@@ -592,6 +615,40 @@ export class DatasetsResource extends BaseResource {
592
615
  const dsEnc = encodeURIComponent(datasetName);
593
616
  await this.client.put(`/public/api/projects/${this.enc(projectKey)}/datasets/${dsEnc}/schema`, { columns, });
594
617
  }
618
+ /**
619
+ * Detect the schema of a SQL dataset (table or query mode) with DSS and
620
+ * store it: `POST /actions/testAndDetectSettings/externalSQL` (what
621
+ * dataikuapi's `autodetect_settings` calls), then `PUT /schema` with the
622
+ * suggested schema. DSS reports connection, table, and query problems in the
623
+ * response body (HTTP 200), so a failed detection is returned with
624
+ * `detected: false` and the reason instead of thrown.
625
+ */
626
+ async detectSqlSchema(datasetName, projectKey) {
627
+ const base = `/public/api/projects/${this.enc(projectKey)}/datasets/${encodeURIComponent(datasetName)}`;
628
+ try {
629
+ const raw = await this.client.post(`${base}/actions/testAndDetectSettings/externalSQL`);
630
+ const detection = isRecord(raw) ? raw["schemaDetection"] : undefined;
631
+ const newSchema = isRecord(detection) ? detection["newSchema"] : undefined;
632
+ const rawColumns = isRecord(newSchema) && Array.isArray(newSchema["columns"])
633
+ ? newSchema["columns"]
634
+ : [];
635
+ const columns = rawColumns.flatMap((column) => isRecord(column) && typeof column["name"] === "string"
636
+ && typeof column["type"] === "string"
637
+ ? [{ name: column["name"], type: column["type"], },]
638
+ : []);
639
+ if (!isRecord(newSchema) || columns.length === 0) {
640
+ return { detected: false, columns: [], error: describeSqlDetectionFailure(raw), };
641
+ }
642
+ await this.client.put(`${base}/schema`, newSchema);
643
+ return { detected: true, columns, };
644
+ }
645
+ catch (error) {
646
+ if (error instanceof DataikuError) {
647
+ return { detected: false, columns: [], error: error.message.split("\n")[0] ?? "", };
648
+ }
649
+ throw error;
650
+ }
651
+ }
595
652
  /**
596
653
  * Get the raw dataset schema object exactly as the DSS schema endpoint
597
654
  * returns it, without schema validation — used for deterministic comparison.
@@ -859,7 +916,7 @@ export class DatasetsResource extends BaseResource {
859
916
  const enc = encodeURIComponent(pk);
860
917
  const explicitType = opts.dsType;
861
918
  let dsType = explicitType
862
- ?? (opts.table ? DEFAULT_DATABASE_DATASET_TYPE : DEFAULT_FILESYSTEM_DATASET_TYPE);
919
+ ?? (opts.table || opts.query ? DEFAULT_DATABASE_DATASET_TYPE : DEFAULT_FILESYSTEM_DATASET_TYPE);
863
920
  let body = buildDatasetCreateBody({
864
921
  projectKey: pk,
865
922
  datasetName: opts.datasetName,
@@ -868,6 +925,7 @@ export class DatasetsResource extends BaseResource {
868
925
  table: opts.table,
869
926
  dbSchema: opts.dbSchema,
870
927
  catalog: opts.catalog,
928
+ query: opts.query,
871
929
  formatType: opts.formatType,
872
930
  formatParams: opts.formatParams,
873
931
  managed: opts.managed,
@@ -899,6 +957,7 @@ export class DatasetsResource extends BaseResource {
899
957
  table: opts.table,
900
958
  dbSchema: opts.dbSchema,
901
959
  catalog: opts.catalog,
960
+ query: opts.query,
902
961
  formatType: opts.formatType,
903
962
  formatParams: opts.formatParams,
904
963
  managed: opts.managed,
@@ -923,6 +982,7 @@ export class DatasetsResource extends BaseResource {
923
982
  table: opts.table,
924
983
  dbSchema: opts.dbSchema,
925
984
  catalog: opts.catalog,
985
+ query: opts.query,
926
986
  formatType: opts.formatType,
927
987
  formatParams: opts.formatParams,
928
988
  managed: opts.managed,
@@ -1,11 +1,21 @@
1
1
  import type { DataikuGetOptions } from "../client.js";
2
- import type { FlowZone, FlowZoneCreateOptions, FlowZoneObjectType, FlowZoneUpdateOptions } from "../schemas.js";
2
+ import type { FlowZone, FlowZoneCreateOptions, FlowZoneObjectType, FlowZonePosition, FlowZoneUpdateOptions } from "../schemas.js";
3
3
  import { BaseResource } from "./base.js";
4
4
  export type FlowZoneItemInput = {
5
5
  objectId: string;
6
6
  objectType: FlowZoneObjectType;
7
7
  projectKey?: string;
8
8
  };
9
+ export type FlowZoneRecreateOptions = {
10
+ position: FlowZonePosition;
11
+ name?: string;
12
+ color?: string;
13
+ projectKey?: string;
14
+ };
15
+ export type FlowZoneRecreateResult = {
16
+ zone: FlowZone;
17
+ replacedZoneId: string;
18
+ };
9
19
  export declare class FlowZonesResource extends BaseResource {
10
20
  /**
11
21
  * List all flow zones in a project. Pass `timeoutMs` to bound the whole
@@ -14,10 +24,26 @@ export declare class FlowZonesResource extends BaseResource {
14
24
  list(projectKey?: string, options?: DataikuGetOptions): Promise<FlowZone[]>;
15
25
  /** Get one flow zone by id. */
16
26
  get(zoneId: string, projectKey?: string): Promise<FlowZone>;
17
- /** Create a flow zone. */
27
+ /**
28
+ * Create a flow zone. DSS stores `position` only while the project's manual
29
+ * zone positioning is off; with it on, DSS auto-places the zone.
30
+ */
18
31
  create(opts: FlowZoneCreateOptions): Promise<FlowZone>;
19
- /** Update flow zone settings such as name, color, and manual position. */
32
+ /**
33
+ * Update flow zone name and color. DSS's public API ignores `position` on
34
+ * this endpoint; use {@link recreate} to move an existing zone.
35
+ */
20
36
  update(zoneId: string, opts: FlowZoneUpdateOptions): Promise<FlowZone>;
37
+ /**
38
+ * Replace a zone with a new one at `position`: DSS honors a zone position
39
+ * only on creation, and only while the project's manual zone positioning is
40
+ * off (otherwise it auto-places the new zone). The replacement receives the
41
+ * old zone's name, color, metadata (tags, custom fields, checklists, ...),
42
+ * items, and shared items; the old zone is deleted once it is empty. The zone
43
+ * id changes, so anything referencing the old id (for example scenario zone
44
+ * builds) must be updated.
45
+ */
46
+ recreate(zoneId: string, opts: FlowZoneRecreateOptions): Promise<FlowZoneRecreateResult>;
21
47
  /** Delete a flow zone. DSS moves its items back to the default zone. */
22
48
  delete(zoneId: string, projectKey?: string): Promise<void>;
23
49
  /** Move items into a flow zone. */
@@ -23,7 +23,10 @@ export class FlowZonesResource extends BaseResource {
23
23
  const raw = await this.client.get(`/public/api/projects/${this.enc(projectKey)}/flow/zones/${encodeURIComponent(zoneId)}`);
24
24
  return this.client.safeParse(FlowZoneSchema, raw, "flowZones.get");
25
25
  }
26
- /** Create a flow zone. */
26
+ /**
27
+ * Create a flow zone. DSS stores `position` only while the project's manual
28
+ * zone positioning is off; with it on, DSS auto-places the zone.
29
+ */
27
30
  async create(opts) {
28
31
  const raw = await this.client.post(`/public/api/projects/${this.enc(opts.projectKey)}/flow/zones`, {
29
32
  name: opts.name,
@@ -32,17 +35,64 @@ export class FlowZonesResource extends BaseResource {
32
35
  });
33
36
  return this.client.safeParse(FlowZoneSchema, raw, "flowZones.create");
34
37
  }
35
- /** Update flow zone settings such as name, color, and manual position. */
38
+ /**
39
+ * Update flow zone name and color. DSS's public API ignores `position` on
40
+ * this endpoint; use {@link recreate} to move an existing zone.
41
+ */
36
42
  async update(zoneId, opts) {
37
43
  const current = await this.get(zoneId, opts.projectKey);
38
44
  const merged = deepMerge(current, {
39
45
  ...(opts.name !== undefined ? { name: opts.name, } : {}),
40
46
  ...(opts.color !== undefined ? { color: opts.color, } : {}),
41
- ...(opts.position !== undefined ? { position: opts.position, } : {}),
42
47
  });
43
48
  await this.client.putVoid(`/public/api/projects/${this.enc(opts.projectKey)}/flow/zones/${encodeURIComponent(zoneId)}`, merged);
44
49
  return this.get(zoneId, opts.projectKey);
45
50
  }
51
+ /**
52
+ * Replace a zone with a new one at `position`: DSS honors a zone position
53
+ * only on creation, and only while the project's manual zone positioning is
54
+ * off (otherwise it auto-places the new zone). The replacement receives the
55
+ * old zone's name, color, metadata (tags, custom fields, checklists, ...),
56
+ * items, and shared items; the old zone is deleted once it is empty. The zone
57
+ * id changes, so anything referencing the old id (for example scenario zone
58
+ * builds) must be updated.
59
+ */
60
+ async recreate(zoneId, opts) {
61
+ const base = `/public/api/projects/${this.enc(opts.projectKey)}/flow/zones`;
62
+ const old = await this.get(zoneId, opts.projectKey);
63
+ const created = await this.create({
64
+ name: opts.name ?? old.name,
65
+ color: opts.color ?? old.color,
66
+ position: opts.position,
67
+ projectKey: opts.projectKey,
68
+ });
69
+ const completed = ["create",];
70
+ try {
71
+ const { id: _id, items, shared, position: _position, ...metadata } = old;
72
+ await this.client.putVoid(`${base}/${encodeURIComponent(created.id)}`, {
73
+ ...created,
74
+ ...metadata,
75
+ id: created.id,
76
+ name: created.name,
77
+ color: created.color,
78
+ position: created.position,
79
+ });
80
+ completed.push("metadata");
81
+ if (items && items.length > 0) {
82
+ await this.moveItems(created.id, items, opts.projectKey);
83
+ }
84
+ completed.push("items");
85
+ for (const item of shared ?? []) {
86
+ await this.client.post(`${base}/${encodeURIComponent(created.id)}/shared`, normalizeZoneItem(item));
87
+ }
88
+ completed.push("shared");
89
+ await this.delete(old.id, opts.projectKey);
90
+ }
91
+ catch (error) {
92
+ throw new ClientValidationError(`Recreating flow zone ${old.id} at a new position stopped after ${completed.at(-1)}; both zones may now exist.`, "ambiguous_outcome", "Inspect both zones with dss flow-zone list, then move remaining items and delete the zone you no longer need.", { replacedZoneId: old.id, zoneId: created.id, completed, }, { cause: error, });
93
+ }
94
+ return { zone: await this.get(created.id, opts.projectKey), replacedZoneId: old.id, };
95
+ }
46
96
  /** Delete a flow zone. DSS moves its items back to the default zone. */
47
97
  async delete(zoneId, projectKey) {
48
98
  await this.client.del(`/public/api/projects/${this.enc(projectKey)}/flow/zones/${encodeURIComponent(zoneId)}`);
@@ -23,12 +23,30 @@ export interface JobLogSummary {
23
23
  }
24
24
  /** Why a requested job log could not be retrieved. */
25
25
  export type JobLogUnavailableReason = "not_found" | "error";
26
+ /**
27
+ * A requested build target that no flow object can ever produce: a managed
28
+ * dataset with no recipe writing it.
29
+ */
30
+ export interface JobUnbuildableTarget {
31
+ type: "DATASET";
32
+ id: string;
33
+ projectKey: string;
34
+ reason: "no_producing_recipe";
35
+ }
36
+ /** Why a job that ended DONE is reported as failed because it built nothing. */
37
+ export interface JobNothingToBuildFailure {
38
+ code: "nothing_to_build";
39
+ message: string;
40
+ targets: JobUnbuildableTarget[];
41
+ }
26
42
  /** Result of a completed job wait, including log-unavailable metadata. */
27
43
  export type JobWaitOutcome = JobWaitResult & {
28
44
  logSummary?: JobLogSummary;
29
45
  logUnavailable?: JobLogUnavailableReason;
30
46
  /** True when the job itself no longer exists on the server. */
31
47
  removed?: boolean;
48
+ /** Set (with `success: false`) when a DONE job ran no activity and a target has no producer. */
49
+ failure?: JobNothingToBuildFailure;
32
50
  };
33
51
  export interface JobBuildTarget {
34
52
  id: string;
@@ -120,6 +138,12 @@ export declare class JobsResource extends BaseResource {
120
138
  * confirmed gone. Explicit `includeLogs`/`summary` requests stay honest:
121
139
  * `log`/`logSummary` are only present when logs were actually retrieved.
122
140
  *
141
+ * A DONE job that ran no activity (`progress.total === 0`) is also what DSS
142
+ * reports for an already-up-to-date build, so it stays successful unless an
143
+ * output is a managed dataset that no recipe writes: then nothing can ever
144
+ * build it, and the outcome is `success: false` with
145
+ * `failure.code === "nothing_to_build"`.
146
+ *
123
147
  * On timeout, returns `{ success: false, ... }` rather than throwing.
124
148
  */
125
149
  wait(jobId: string, opts?: {
@@ -133,6 +157,16 @@ export declare class JobsResource extends BaseResource {
133
157
  timeoutMs?: number;
134
158
  projectKey?: string;
135
159
  }): Promise<JobWaitOutcome>;
160
+ /**
161
+ * Dataset outputs of a job that ran no activity which nothing can produce.
162
+ * DSS ends such a job DONE exactly like an already-up-to-date build, so the
163
+ * job status cannot tell them apart; the flow graph can: a target is
164
+ * unbuildable when it is a managed dataset (DSS owns its storage, so a
165
+ * recipe is the only way to fill it) that no recipe writes. Unmanaged
166
+ * datasets (uploads, external tables) are sources and legitimately build
167
+ * nothing. Lookups that fail leave the DONE outcome untouched.
168
+ */
169
+ private unbuildableTargets;
136
170
  /**
137
171
  * Probe whether a job still exists. Returns `true` when the server reports
138
172
  * it as not found, `false` when it is still visible, and `undefined` when
@@ -1,6 +1,7 @@
1
1
  import { ClientValidationError, DataikuError, } from "../errors.js";
2
2
  import { JobSummaryArraySchema, } from "../schemas.js";
3
3
  import { computeNextPollDelayMs, DEFAULT_POLL_INTERVAL_MS, DEFAULT_TIMEOUT_MS, isRequestDeadlineError, } from "../utils/polling.js";
4
+ import { asRecord, } from "../utils/records.js";
4
5
  import { BaseResource, } from "./base.js";
5
6
  const DEFAULT_MAX_LOG_LINES = 500;
6
7
  /**
@@ -263,6 +264,10 @@ function summarizeJobLog(state, log, maxLines, elapsedMs) {
263
264
  ...(progress ? { progress, } : {}),
264
265
  };
265
266
  }
267
+ function nothingToBuildMessage(targets) {
268
+ const names = targets.map((target) => `${target.projectKey}.${target.id}`).join(", ");
269
+ return `The job ran no activity: ${names} ${targets.length === 1 ? "is a managed dataset" : "are managed datasets"} that no recipe produces, so nothing was built. Create a recipe that writes ${targets.length === 1 ? "it" : "them"} or build the recipe's output instead.`;
270
+ }
266
271
  export class JobsResource extends BaseResource {
267
272
  /** List jobs in a project. */
268
273
  async list(projectKey) {
@@ -375,6 +380,12 @@ export class JobsResource extends BaseResource {
375
380
  * confirmed gone. Explicit `includeLogs`/`summary` requests stay honest:
376
381
  * `log`/`logSummary` are only present when logs were actually retrieved.
377
382
  *
383
+ * A DONE job that ran no activity (`progress.total === 0`) is also what DSS
384
+ * reports for an already-up-to-date build, so it stays successful unless an
385
+ * output is a managed dataset that no recipe writes: then nothing can ever
386
+ * build it, and the outcome is `success: false` with
387
+ * `failure.code === "nothing_to_build"`.
388
+ *
378
389
  * On timeout, returns `{ success: false, ... }` rather than throwing.
379
390
  */
380
391
  async wait(jobId, opts) {
@@ -492,8 +503,18 @@ export class JobsResource extends BaseResource {
492
503
  }
493
504
  }
494
505
  }
506
+ const unbuildable = success && gs.total === 0
507
+ ? await this.unbuildableTargets(def.outputs, def.projectKey ?? opts?.projectKey)
508
+ : [];
509
+ const failure = unbuildable.length > 0
510
+ ? {
511
+ code: "nothing_to_build",
512
+ message: nothingToBuildMessage(unbuildable),
513
+ targets: unbuildable,
514
+ }
515
+ : undefined;
495
516
  return {
496
- success,
517
+ success: success && failure === undefined,
497
518
  jobId: def.id ?? jobId,
498
519
  state,
499
520
  type: def.type ?? "unknown",
@@ -505,6 +526,7 @@ export class JobsResource extends BaseResource {
505
526
  running: gs.running ?? 0,
506
527
  total: gs.total ?? null,
507
528
  },
529
+ ...(failure !== undefined ? { failure, } : {}),
508
530
  ...(log !== undefined ? { log, } : {}),
509
531
  ...(logSummary !== undefined ? { logSummary, } : {}),
510
532
  ...(logUnavailable !== undefined ? { logUnavailable, } : {}),
@@ -523,6 +545,51 @@ export class JobsResource extends BaseResource {
523
545
  await sleep(Math.min(nextDelayMs, timeout - elapsedMs));
524
546
  }
525
547
  }
548
+ /**
549
+ * Dataset outputs of a job that ran no activity which nothing can produce.
550
+ * DSS ends such a job DONE exactly like an already-up-to-date build, so the
551
+ * job status cannot tell them apart; the flow graph can: a target is
552
+ * unbuildable when it is a managed dataset (DSS owns its storage, so a
553
+ * recipe is the only way to fill it) that no recipe writes. Unmanaged
554
+ * datasets (uploads, external tables) are sources and legitimately build
555
+ * nothing. Lookups that fail leave the DONE outcome untouched.
556
+ */
557
+ async unbuildableTargets(outputs, fallbackProjectKey) {
558
+ if (!Array.isArray(outputs))
559
+ return [];
560
+ const writtenByProject = new Map();
561
+ const unbuildable = [];
562
+ for (const output of outputs) {
563
+ const record = asRecord(output);
564
+ if (record?.type !== "DATASET" || typeof record.targetDataset !== "string")
565
+ continue;
566
+ const id = record.targetDataset;
567
+ const projectKey = typeof record.targetDatasetProjectKey === "string"
568
+ ? record.targetDatasetProjectKey
569
+ : fallbackProjectKey;
570
+ if (projectKey === undefined)
571
+ continue;
572
+ try {
573
+ const details = await this.client.datasets.get(id, projectKey);
574
+ if (details.managed !== true)
575
+ continue;
576
+ let written = writtenByProject.get(projectKey);
577
+ if (written === undefined) {
578
+ const { graph, } = await this.client.projects.flowTopology(projectKey);
579
+ written = new Set(graph.edges.filter((edge) => edge.relation === "writes").map((edge) => edge.to));
580
+ writtenByProject.set(projectKey, written);
581
+ }
582
+ if (!written.has(id)) {
583
+ unbuildable.push({ type: "DATASET", id, projectKey, reason: "no_producing_recipe", });
584
+ }
585
+ }
586
+ catch (error) {
587
+ if (!(error instanceof DataikuError || error instanceof ClientValidationError))
588
+ throw error;
589
+ }
590
+ }
591
+ return unbuildable;
592
+ }
526
593
  /**
527
594
  * Probe whether a job still exists. Returns `true` when the server reports
528
595
  * it as not found, `false` when it is still visible, and `undefined` when
@@ -1,5 +1,6 @@
1
1
  import type { FlowMapOptions, ProjectDetails, ProjectMetadata, ProjectSummary, ProjectTags } from "../schemas.js";
2
2
  import type { AnalyzedFlowMap, FlowMapRendering } from "../utils/flow-analysis.js";
3
+ import type { NormalizedFlowMap } from "../utils/flow-map.js";
3
4
  import { BaseResource } from "./base.js";
4
5
  interface FlowMapTruncationSummary {
5
6
  truncated: boolean;
@@ -150,6 +151,14 @@ export declare class ProjectsResource extends BaseResource {
150
151
  setTags(projectKey: string | undefined, tags: ProjectTags): Promise<void>;
151
152
  /** Get the raw flow graph for a project. */
152
153
  flow(projectKey?: string): Promise<unknown>;
154
+ /**
155
+ * Normalized flow topology (nodes and edges, no metadata lookups) plus its
156
+ * stable fingerprint, excluding visual metadata.
157
+ */
158
+ flowTopology(projectKey?: string): Promise<{
159
+ graph: NormalizedFlowMap;
160
+ topologyFingerprint: string;
161
+ }>;
153
162
  /** Compute a stable fingerprint of flow nodes and edges, excluding visual metadata. */
154
163
  topologyFingerprint(projectKey?: string): Promise<string>;
155
164
  /**
@@ -340,11 +340,18 @@ export class ProjectsResource extends BaseResource {
340
340
  const enc = this.enc(projectKey);
341
341
  return this.client.get(`/public/api/projects/${enc}/flow/graph/`);
342
342
  }
343
+ /**
344
+ * Normalized flow topology (nodes and edges, no metadata lookups) plus its
345
+ * stable fingerprint, excluding visual metadata.
346
+ */
347
+ async flowTopology(projectKey) {
348
+ const pk = this.resolveProjectKey(projectKey);
349
+ const graph = normalizeFlowGraph(await this.flow(pk), pk);
350
+ return { graph, topologyFingerprint: flowTopologyFingerprint(graph), };
351
+ }
343
352
  /** Compute a stable fingerprint of flow nodes and edges, excluding visual metadata. */
344
353
  async topologyFingerprint(projectKey) {
345
- const pk = this.resolveProjectKey(projectKey);
346
- const rawGraph = await this.flow(pk);
347
- return flowTopologyFingerprint(normalizeFlowGraph(rawGraph, pk));
354
+ return (await this.flowTopology(projectKey)).topologyFingerprint;
348
355
  }
349
356
  /**
350
357
  * Build a normalized, optionally truncated flow map for a project.
@@ -33,12 +33,26 @@ export declare function groupingPayloadConfig(opts: RecipeCreateOptions): Groupi
33
33
  * aggregate flags (and `orderColumn` for first/last).
34
34
  */
35
35
  export declare function applyGroupingPayload(payload: Record<string, unknown>, config: GroupingPayloadConfig): Record<string, unknown>;
36
+ /** Prepare option groups, in the default step order. */
37
+ export declare const PREPARE_STEP_KINDS: readonly ["rename", "fillEmpty", "formula", "filter", "dropColumns", "keepColumns"];
38
+ export type PrepareStepKind = (typeof PREPARE_STEP_KINDS)[number];
36
39
  /**
37
- * Prepare (shaker) steps from create options, in a fixed order: rename,
38
- * fill empty, formula column, row filter, drop columns, keep columns. Later
39
- * steps see renamed columns. Empty when no prepare option was given.
40
+ * Prepare (shaker) steps from create options: rename, fill empty, formula
41
+ * column, row filter, drop columns, keep columns. They run in `stepOrder`
42
+ * (flag order on the CLI), defaulting to that sequence, so a step sees the
43
+ * columns the steps before it produced. Empty when no prepare option was given.
40
44
  */
41
45
  export declare function prepareSteps(opts: RecipeCreateOptions): Record<string, unknown>[];
46
+ export interface RecipeCreateWarning {
47
+ code: string;
48
+ [key: string]: unknown;
49
+ }
50
+ /**
51
+ * Warnings about the prepare options that DSS cannot report itself: a
52
+ * `--formula`/`--filter` that runs after a `--rename` but still names a
53
+ * column that rename removed (it would fail or read an empty column).
54
+ */
55
+ export declare function recipeCreateWarnings(opts: RecipeCreateOptions): RecipeCreateWarning[];
42
56
  /**
43
57
  * Pure request construction for POST /recipes/ (no DSS calls). Shared with
44
58
  * `recipe create --plan` so the plan equals the request the command sends.