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
@@ -18,6 +18,13 @@ export declare const REPEATABLE_VALUE_FLAGS: Set<string>;
18
18
  export declare const KNOWN_LONG_FLAGS: Set<string>;
19
19
  export declare function normalizeLongFlag(rawFlagName: string, argv: string[]): string;
20
20
  export declare function isNegativeNumberToken(value: string): boolean;
21
+ /**
22
+ * Whether the token after a value flag is that flag's value rather than the
23
+ * next flag. `-` (stdin), negative numbers, and anything not flag-shaped
24
+ * qualify, so a SQL value beginning with a `-- comment` is a value; `--` alone
25
+ * and `--flag`/`--flag=x` are not.
26
+ */
27
+ export declare function isFlagValueToken(token: string): boolean;
21
28
  export declare function requireFlagValue(flagLabel: string, next: string | undefined): string;
22
29
  export declare function setParsedFlagValue(flags: Record<string, string | boolean>, flagName: string, value: string): void;
23
30
  export interface ParsedArgs {
@@ -81,6 +81,7 @@ export const BOOLEAN_FLAGS = new Set([
81
81
  "allow-same-path",
82
82
  "sync",
83
83
  "validate-objects",
84
+ "recreate-on-move",
84
85
  "errors-only",
85
86
  "keep",
86
87
  "full-log",
@@ -223,6 +224,8 @@ export const VALUE_FLAGS = new Set([
223
224
  "render",
224
225
  "prediction-type",
225
226
  "project-key",
227
+ "query",
228
+ "query-file",
226
229
  "recipe",
227
230
  "reference",
228
231
  "repository",
@@ -353,9 +356,32 @@ export function normalizeLongFlag(rawFlagName, argv) {
353
356
  export function isNegativeNumberToken(value) {
354
357
  return value.startsWith("-") && Number.isFinite(Number(value));
355
358
  }
359
+ /** `--name` or `--name=value`: a token that parses as a long flag. */
360
+ const LONG_FLAG_SHAPE = /^--[A-Za-z][A-Za-z0-9-]*(?:=|$)/;
361
+ /**
362
+ * Whether the token after a value flag is that flag's value rather than the
363
+ * next flag. `-` (stdin), negative numbers, and anything not flag-shaped
364
+ * qualify, so a SQL value beginning with a `-- comment` is a value; `--` alone
365
+ * and `--flag`/`--flag=x` are not.
366
+ */
367
+ export function isFlagValueToken(token) {
368
+ if (token === "-" || isNegativeNumberToken(token))
369
+ return true;
370
+ if (!token.startsWith("-"))
371
+ return true;
372
+ return token.startsWith("--") && token !== "--" && !LONG_FLAG_SHAPE.test(token);
373
+ }
356
374
  export function requireFlagValue(flagLabel, next) {
357
- if (next === undefined || (next !== "-" && next.startsWith("-") && !isNegativeNumberToken(next))) {
358
- throw new UsageError(`Flag ${flagLabel} requires a value.`, "missing_required_flag");
375
+ if (next === undefined || !isFlagValueToken(next)) {
376
+ const inlineForm = flagLabel.startsWith("--") ? `${flagLabel}=VALUE` : undefined;
377
+ const hint = next === undefined || !next.startsWith("-")
378
+ ? undefined
379
+ : flagLabel === "--sql"
380
+ ? 'A SQL value that starts with `--` (a comment) can be passed as --sql="-- ...", from a file with --sql-file PATH, or on stdin with --stdin.'
381
+ : inlineForm
382
+ ? `If the value itself starts with \`-\`, pass it as ${inlineForm}.`
383
+ : undefined;
384
+ throw new UsageError(`Flag ${flagLabel} requires a value.`, "missing_required_flag", hint);
359
385
  }
360
386
  return next;
361
387
  }
@@ -1,2 +1,14 @@
1
1
  import type { DatasetDetails } from "../../schemas.js";
2
+ export interface DatasetSqlSourceFlags {
3
+ table?: string;
4
+ dbSchema?: string;
5
+ catalog?: string;
6
+ query?: string;
7
+ }
8
+ /**
9
+ * The SQL source of `dataset create` (`--table [--schema S] [--catalog C]` or
10
+ * `--query SQL`/`--query-file PATH`), validated against the dataset type.
11
+ * Empty when no SQL flag is given.
12
+ */
13
+ export declare function datasetSqlSourceFromFlags(flags: Record<string, string | boolean>, dsType: string): DatasetSqlSourceFlags;
2
14
  export declare function datasetSourceSummary(details: DatasetDetails): Record<string, unknown>;
@@ -1,3 +1,39 @@
1
+ import { assertSqlDatasetSource, } from "../../resources/dataset-create.js";
2
+ import { readInputText, stripUtf8Bom, } from "../coerce.js";
3
+ import { UsageError, } from "../usage.js";
4
+ /**
5
+ * The SQL source of `dataset create` (`--table [--schema S] [--catalog C]` or
6
+ * `--query SQL`/`--query-file PATH`), validated against the dataset type.
7
+ * Empty when no SQL flag is given.
8
+ */
9
+ export function datasetSqlSourceFromFlags(flags, dsType) {
10
+ const text = (flag) => {
11
+ const value = flags[flag];
12
+ if (value === undefined)
13
+ return undefined;
14
+ if (typeof value !== "string") {
15
+ throw new UsageError(`--${flag} requires a value.`, "missing_required_flag");
16
+ }
17
+ return value;
18
+ };
19
+ if (flags["query"] !== undefined && flags["query-file"] !== undefined) {
20
+ throw new UsageError("Conflicting input sources: --query, --query-file. Provide exactly one.", "conflicting_input_sources", undefined, { sources: ["query", "query-file",], });
21
+ }
22
+ const queryFile = text("query-file");
23
+ const inline = text("query");
24
+ const source = {
25
+ ...(text("table") !== undefined ? { table: text("table"), } : {}),
26
+ ...(text("schema") !== undefined ? { dbSchema: text("schema"), } : {}),
27
+ ...(text("catalog") !== undefined ? { catalog: text("catalog"), } : {}),
28
+ ...(queryFile !== undefined
29
+ ? { query: stripUtf8Bom(readInputText(queryFile, "--query-file")), }
30
+ : inline !== undefined
31
+ ? { query: inline, }
32
+ : {}),
33
+ };
34
+ assertSqlDatasetSource(dsType, source);
35
+ return source;
36
+ }
1
37
  export function datasetSourceSummary(details) {
2
38
  const params = details.params ?? {};
3
39
  return {
@@ -1,6 +1,7 @@
1
1
  import type { DataikuClient } from "../../client.js";
2
2
  import type { FlowZoneItemInput } from "../../resources/flow-zones.js";
3
3
  import type { FlowZone, FlowZoneObjectType, FlowZonePosition } from "../../schemas.js";
4
+ import type { NormalizedFlowMap } from "../../utils/flow-map.js";
4
5
  export declare function flowZoneId(value: string): string;
5
6
  export declare function flowZoneName(value: string | boolean | undefined): string;
6
7
  export declare function flowZoneColor(value: string | boolean | undefined): string | undefined;
@@ -18,6 +19,8 @@ export interface FlowZoneOrganizeZonePlan {
18
19
  color?: string;
19
20
  position?: FlowZonePosition;
20
21
  items: FlowZoneItemInput[];
22
+ /** Recipe/output unit members added by {@link applyFlowZoneUnits}; also present in `items`. */
23
+ impliedItems?: FlowZoneItemInput[];
21
24
  }
22
25
  export interface FlowZoneOrganizePlan {
23
26
  topologyFingerprint?: string;
@@ -60,7 +63,25 @@ export declare function flowZoneExplicitItems(zone: FlowZone): FlowZoneItemInput
60
63
  export declare function flowZonePlanFromZones(zones: FlowZone[], topologyFingerprint: string): FlowZoneOrganizePlan;
61
64
  export declare function flowZoneMoveDelta(existing: FlowZone | undefined, plannedItems: FlowZoneItemInput[]): FlowZoneItemInput[];
62
65
  export declare function flowZonePruneItems(existing: FlowZone | undefined, plannedItemKeys: Set<string>): FlowZoneItemInput[];
66
+ /**
67
+ * The position an existing zone must move to, if the plan changes it. DSS
68
+ * honors a zone position only on creation, so a move means recreating the zone.
69
+ */
70
+ export declare function flowZoneRepositionTarget(plan: FlowZoneOrganizeZonePlan, existing: FlowZone): FlowZonePosition | undefined;
63
71
  export declare function flowZoneOrganizeStep(plan: FlowZoneOrganizeZonePlan, existing: FlowZone | undefined, sync: boolean, plannedItemKeys: Set<string>): Record<string, unknown>;
72
+ /**
73
+ * Enforce the DSS recipe/output zone invariant on a plan. Rejects plans that
74
+ * place members of one unit in different zones (they would never converge),
75
+ * and adds the unlisted members of each planned unit to that zone as
76
+ * `impliedItems`, so moves, `--sync` pruning, and reruns all agree with what
77
+ * DSS actually does.
78
+ */
79
+ export declare function applyFlowZoneUnits(plan: FlowZoneOrganizePlan, graph: NormalizedFlowMap, zones: FlowZone[], projectKey: string | undefined): FlowZoneOrganizePlan;
80
+ /**
81
+ * Default-zone objects an organization plan has to place: recipes (their
82
+ * outputs follow) and objects no recipe produces, such as uploaded sources.
83
+ */
84
+ export declare function flowZoneUnassignedItems(graph: NormalizedFlowMap, zones: FlowZone[]): FlowZoneItemInput[];
64
85
  export declare function flowZoneValidationBucket(index: FlowZoneOrganizeValidationIndex, objectType: FlowZoneObjectType): Set<string>;
65
86
  export declare function flowZoneValidationIndex(client: DataikuClient, projectKey: string | undefined): Promise<FlowZoneOrganizeValidationIndex>;
66
87
  export declare function validateFlowZoneOrganizeObjects(client: DataikuClient, plan: FlowZoneOrganizePlan, projectKey: string | undefined): Promise<FlowZoneOrganizeValidationResult>;
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, } from "node:fs";
2
+ import { ClientValidationError, } from "../../errors.js";
2
3
  import { asRecord, } from "../../utils/records.js";
3
4
  import { compareStrings, } from "../../utils/stable-hash.js";
4
5
  import { finiteNumberField, jsonInput, optionalStringField, parseJsonObject, requiredStringArray, splitCsvFlag, } from "../coerce.js";
@@ -319,6 +320,16 @@ export function flowZonePruneItems(existing, plannedItemKeys) {
319
320
  return [];
320
321
  return flowZoneExplicitItems(existing).filter((item) => !plannedItemKeys.has(flowZoneItemKey(item)));
321
322
  }
323
+ /**
324
+ * The position an existing zone must move to, if the plan changes it. DSS
325
+ * honors a zone position only on creation, so a move means recreating the zone.
326
+ */
327
+ export function flowZoneRepositionTarget(plan, existing) {
328
+ return plan.position !== undefined
329
+ && !flowZoneSamePosition(flowZoneCurrentPosition(existing), plan.position)
330
+ ? plan.position
331
+ : undefined;
332
+ }
322
333
  export function flowZoneOrganizeStep(plan, existing, sync, plannedItemKeys) {
323
334
  ensureFlowZonePlanTarget(plan, existing);
324
335
  const update = {};
@@ -326,10 +337,8 @@ export function flowZoneOrganizeStep(plan, existing, sync, plannedItemKeys) {
326
337
  update.name = plan.name;
327
338
  if (existing && plan.color && plan.color !== existing.color)
328
339
  update.color = plan.color;
329
- if (existing && plan.position !== undefined
330
- && !flowZoneSamePosition(flowZoneCurrentPosition(existing), plan.position)) {
331
- update.position = plan.position;
332
- }
340
+ const recreatePosition = existing ? flowZoneRepositionTarget(plan, existing) : undefined;
341
+ const recreate = recreatePosition ? { position: recreatePosition, } : undefined;
333
342
  const pruneItems = sync ? flowZonePruneItems(existing, plannedItemKeys) : [];
334
343
  const moveItems = flowZoneMoveDelta(existing, plan.items);
335
344
  return {
@@ -341,10 +350,141 @@ export function flowZoneOrganizeStep(plan, existing, sync, plannedItemKeys) {
341
350
  },
342
351
  ...(existing ? { existing: flowZoneSummary(existing), } : { create: true, }),
343
352
  ...(Object.keys(update).length > 0 ? { update, } : {}),
353
+ ...(recreate ? { recreate, } : {}),
344
354
  moveItems,
355
+ ...(plan.impliedItems && plan.impliedItems.length > 0
356
+ ? { impliedItems: plan.impliedItems, }
357
+ : {}),
345
358
  ...(pruneItems.length > 0 ? { pruneItems, } : {}),
346
359
  };
347
360
  }
361
+ function flowNodeObjectType(node, knownTypes) {
362
+ switch (node.kind) {
363
+ case "recipe":
364
+ return "RECIPE";
365
+ case "dataset":
366
+ return "DATASET";
367
+ case "folder":
368
+ return "MANAGED_FOLDER";
369
+ default:
370
+ return knownTypes.get(node.id);
371
+ }
372
+ }
373
+ /**
374
+ * DSS keeps a recipe and its outputs in one zone: moving either drags the
375
+ * others. Each unit is keyed by its recipe id and lists the recipe plus every
376
+ * object the recipe writes.
377
+ */
378
+ function flowZoneUnits(graph) {
379
+ const kindById = new Map(graph.nodes.map((node) => [node.id, node.kind,]));
380
+ const units = new Map();
381
+ for (const node of graph.nodes) {
382
+ if (node.kind === "recipe")
383
+ units.set(node.id, [node.id,]);
384
+ }
385
+ for (const edge of graph.edges) {
386
+ if (kindById.get(edge.from) !== "recipe" || kindById.get(edge.to) === "recipe")
387
+ continue;
388
+ units.get(edge.from)?.push(edge.to);
389
+ }
390
+ return units;
391
+ }
392
+ function explicitTypesById(zones) {
393
+ const types = new Map();
394
+ for (const zone of zones) {
395
+ for (const item of flowZoneItems(zone)) {
396
+ if (!item.projectKey)
397
+ types.set(item.objectId, item.objectType);
398
+ }
399
+ }
400
+ return types;
401
+ }
402
+ /**
403
+ * Enforce the DSS recipe/output zone invariant on a plan. Rejects plans that
404
+ * place members of one unit in different zones (they would never converge),
405
+ * and adds the unlisted members of each planned unit to that zone as
406
+ * `impliedItems`, so moves, `--sync` pruning, and reruns all agree with what
407
+ * DSS actually does.
408
+ */
409
+ export function applyFlowZoneUnits(plan, graph, zones, projectKey) {
410
+ const zoneIndexById = new Map();
411
+ plan.zones.forEach((zonePlan, index) => {
412
+ for (const item of zonePlan.items) {
413
+ // Shared objects from other projects never move with a local recipe.
414
+ if (!item.projectKey || item.projectKey === projectKey)
415
+ zoneIndexById.set(item.objectId, index);
416
+ }
417
+ });
418
+ const nodeById = new Map(graph.nodes.map((node) => [node.id, node,]));
419
+ const knownTypes = explicitTypesById(zones);
420
+ const conflicts = [];
421
+ const implied = plan.zones.map(() => []);
422
+ for (const [recipe, members,] of flowZoneUnits(graph)) {
423
+ const planned = members.filter((member) => zoneIndexById.has(member));
424
+ if (planned.length === 0)
425
+ continue;
426
+ const indexes = new Set(planned.map((member) => zoneIndexById.get(member)));
427
+ if (indexes.size > 1) {
428
+ const byZone = {};
429
+ for (const member of planned) {
430
+ const label = flowZonePlanLabel(plan.zones[zoneIndexById.get(member)]);
431
+ (byZone[label] ??= []).push(member);
432
+ }
433
+ conflicts.push({ recipe, zones: byZone, });
434
+ continue;
435
+ }
436
+ const [index,] = indexes;
437
+ for (const member of members) {
438
+ if (zoneIndexById.has(member))
439
+ continue;
440
+ const node = nodeById.get(member);
441
+ const objectType = node ? flowNodeObjectType(node, knownTypes) : undefined;
442
+ if (objectType)
443
+ implied[index].push({ objectType, objectId: member, });
444
+ }
445
+ }
446
+ if (conflicts.length > 0) {
447
+ throw new ClientValidationError(`Flow zone plan splits ${conflicts.length} recipe(s) from their outputs across zones; DSS keeps a recipe and its outputs in one zone.`, "validation_failed", "Assign each recipe to one zone (its outputs follow automatically), or list the recipe and all its outputs in the same zone.", { conflicts, });
448
+ }
449
+ return {
450
+ ...plan,
451
+ zones: plan.zones.map((zonePlan, index) => implied[index].length === 0
452
+ ? zonePlan
453
+ : {
454
+ ...zonePlan,
455
+ items: [...zonePlan.items, ...implied[index],],
456
+ impliedItems: implied[index],
457
+ }),
458
+ };
459
+ }
460
+ /**
461
+ * Default-zone objects an organization plan has to place: recipes (their
462
+ * outputs follow) and objects no recipe produces, such as uploaded sources.
463
+ */
464
+ export function flowZoneUnassignedItems(graph, zones) {
465
+ const assigned = new Set();
466
+ for (const zone of zones) {
467
+ if (zone.id === "default")
468
+ continue;
469
+ for (const item of flowZoneExplicitItems(zone))
470
+ assigned.add(item.objectId);
471
+ }
472
+ const produced = new Set();
473
+ for (const members of flowZoneUnits(graph).values()) {
474
+ for (const member of members.slice(1))
475
+ produced.add(member);
476
+ }
477
+ const knownTypes = explicitTypesById(zones);
478
+ const items = [];
479
+ for (const node of graph.nodes) {
480
+ if (assigned.has(node.id) || produced.has(node.id))
481
+ continue;
482
+ const objectType = flowNodeObjectType(node, knownTypes);
483
+ if (objectType)
484
+ items.push({ objectType, objectId: node.id, });
485
+ }
486
+ return items.sort((a, b) => compareStrings(flowZoneItemKey(a), flowZoneItemKey(b)));
487
+ }
348
488
  export function flowZoneValidationBucket(index, objectType) {
349
489
  switch (objectType) {
350
490
  case "DATASET":
@@ -187,6 +187,12 @@ export class CommandResultFailure extends Error {
187
187
  export function commandFailureMessage(result) {
188
188
  if (isFailedWaitResult(result)) {
189
189
  const record = result;
190
+ const failure = record.failure;
191
+ if (failure !== null && typeof failure === "object" && !Array.isArray(failure)) {
192
+ const message = failure.message;
193
+ if (typeof message === "string" && message.length > 0)
194
+ return message;
195
+ }
190
196
  const state = typeof record.state === "string"
191
197
  ? record.state
192
198
  : typeof record.outcome === "string"
@@ -16,11 +16,12 @@ import { schemaPropagationOptionsFromFlags, } from "./commands/flow.js";
16
16
  import { mlTaskFieldsFromFlags, mlTaskReguessOptionsFromFlags, } from "./commands/ml-task.js";
17
17
  import { deployRunOptionsFromFlags, experimentsDatasetOptionsFromFlags, inferenceInfoFromFlags, } from "./commands/mlflow.js";
18
18
  import { projectLibraryPutPayload, } from "./commands/project-library.js";
19
- import { recipeCreateOptionsFromFlags, } from "./commands/recipe.js";
19
+ import { enqueueRecipeCreateWarnings, recipeCreateOptionsFromFlags, } from "./commands/recipe.js";
20
20
  import { resolveSqlQueryInvocation, } from "./commands/sql.js";
21
21
  import { buildRegistryEntry, splitPackageSpec, } from "./contract.js";
22
22
  import { ambientProjectKey, } from "./env.js";
23
23
  import { executionMode, } from "./flags.js";
24
+ import { datasetSqlSourceFromFlags, } from "./helpers/dataset.js";
24
25
  import { flowZoneColor, flowZoneMoveItems, flowZoneName, } from "./helpers/flow-zone.js";
25
26
  import { recipeBackupPath, recipeRunShouldWait, } from "./helpers/recipe.js";
26
27
  import { diagnosticsComputationFromFlags, } from "./helpers/trained-model.js";
@@ -419,6 +420,50 @@ export function commandPlanShape(resource, action, args, flags, entry, projectKe
419
420
  endpoint: projectEndpoint(`/wiki/${encodeURIComponent(id)}`),
420
421
  identifiers: { article: id, },
421
422
  };
423
+ case "wiki.attach": {
424
+ const articleEndpoint = projectEndpoint(`/wiki/${encodeURIComponent(id)}`);
425
+ const upload = {
426
+ method: "POST",
427
+ endpoint: `${articleEndpoint}/upload`,
428
+ payload: uploadPayload(args[1]),
429
+ };
430
+ return {
431
+ ...upload,
432
+ identifiers: { article: id, localPath: args[1], },
433
+ requests: [
434
+ {
435
+ sequence: 1,
436
+ method: "GET",
437
+ endpoint: articleEndpoint,
438
+ purpose: "read current attachments",
439
+ },
440
+ { sequence: 2, ...upload, },
441
+ {
442
+ sequence: 3,
443
+ method: "GET",
444
+ endpoint: articleEndpoint,
445
+ assert: { newAttachments: 1, },
446
+ },
447
+ ],
448
+ };
449
+ }
450
+ case "wiki.detach": {
451
+ const articleEndpoint = projectEndpoint(`/wiki/${encodeURIComponent(id)}`);
452
+ return {
453
+ method: "PUT",
454
+ endpoint: articleEndpoint,
455
+ identifiers: { article: id, smartId: args[1], },
456
+ requests: [
457
+ { sequence: 1, method: "GET", endpoint: articleEndpoint, },
458
+ {
459
+ sequence: 2,
460
+ method: "PUT",
461
+ endpoint: articleEndpoint,
462
+ payload: "<article data without the attachment with the given smartId>",
463
+ },
464
+ ],
465
+ };
466
+ }
422
467
  case "dashboard.create": {
423
468
  const data = jsonInput(flags);
424
469
  const flagName = flags["name"];
@@ -730,8 +775,16 @@ export function commandPlanShape(resource, action, args, flags, entry, projectKe
730
775
  if (!connection && dsType.toLowerCase() !== "uploadedfiles") {
731
776
  throw new UsageError("--connection is required unless --type is UploadedFiles.");
732
777
  }
778
+ const sqlSource = datasetSqlSourceFromFlags(flags, dsType);
779
+ const hasSqlSource = sqlSource.table !== undefined || sqlSource.query !== undefined;
733
780
  const endpoint = projectEndpoint("/datasets/"); // throws without a project key
734
781
  return {
782
+ ...(hasSqlSource
783
+ ? {
784
+ exact: false,
785
+ reason: "The POST is exact; apply then detects the schema (POST /actions/testAndDetectSettings/externalSQL) and stores it (PUT /schema).",
786
+ }
787
+ : {}),
735
788
  method: "POST",
736
789
  endpoint,
737
790
  identifiers: { name, },
@@ -740,6 +793,7 @@ export function commandPlanShape(resource, action, args, flags, entry, projectKe
740
793
  datasetName: name,
741
794
  connection,
742
795
  dsType,
796
+ ...sqlSource,
743
797
  }),
744
798
  };
745
799
  }
@@ -924,7 +978,9 @@ export function commandPlanShape(resource, action, args, flags, entry, projectKe
924
978
  }
925
979
  const endpoint = projectEndpoint("/recipes/"); // throws without a project key
926
980
  // Same flag mapping and body construction as the handler and RecipesResource.create.
927
- const { recipePrototype, creationSettings, grouping, prepareSteps, } = buildRecipeCreateRequest(recipeCreateOptionsFromFlags(flags), projectKey);
981
+ const createOptions = recipeCreateOptionsFromFlags(flags);
982
+ enqueueRecipeCreateWarnings(createOptions);
983
+ const { recipePrototype, creationSettings, grouping, prepareSteps, } = buildRecipeCreateRequest(createOptions, projectKey);
928
984
  const edits = grouping
929
985
  ? { grouping, }
930
986
  : prepareSteps.length > 0
@@ -7,6 +7,8 @@ export interface CommandPayloadSchema {
7
7
  dataFileFlag?: boolean;
8
8
  contentType?: "application/json" | "text/plain";
9
9
  jsonShape?: "object" | "array";
10
+ /** Precise JSON Schema of the payload, published as the contract's `schemas.input`. */
11
+ jsonSchema?: Record<string, unknown>;
10
12
  }
11
13
  export interface CommandFlagChoice {
12
14
  oneOf: string[][];
package/dist/src/cli.js CHANGED
@@ -5,7 +5,7 @@ import { resolve, } from "node:path";
5
5
  import { num, seenJsonInputCredentials, unknownJsonInput, } from "./cli/coerce.js";
6
6
  import { commands, } from "./cli/commands/index.js";
7
7
  import { ambientProjectKey, loadEnvFile, } from "./cli/env.js";
8
- import { executionMode, FLAG_ALIASES, isNegativeNumberToken, parseArgs, rawPositionals, } from "./cli/flags.js";
8
+ import { executionMode, FLAG_ALIASES, isFlagValueToken, parseArgs, rawPositionals, } from "./cli/flags.js";
9
9
  import { cleanupLedgerEntry, } from "./cli/helpers/cleanup.js";
10
10
  import { shapeListResult, } from "./cli/list-output.js";
11
11
  import { commandFailureExitCode, CommandResultFailure, enqueueCliWarning, flushCliWarnings, isFailedWaitResult, planResult, projectResultFields, setOutputFieldProjection, writeCommandResult, } from "./cli/output.js";
@@ -533,8 +533,7 @@ function redactArgv(argv) {
533
533
  else {
534
534
  redacted.push(arg);
535
535
  const next = argv[index + 1];
536
- if (next !== undefined
537
- && (next === "-" || !next.startsWith("-") || isNegativeNumberToken(next))) {
536
+ if (next !== undefined && isFlagValueToken(next)) {
538
537
  redacted.push("***");
539
538
  index++;
540
539
  }
@@ -1247,6 +1246,10 @@ function dssDiagnosticDetails(err) {
1247
1246
  function isAmbiguousMutationFailure(err, context) {
1248
1247
  if (!(err instanceof DataikuError))
1249
1248
  return false;
1249
+ // DSS definitively refused the operation (e.g. the database rejected the SQL):
1250
+ // nothing took effect, so there is no outcome to verify.
1251
+ if (err.definiteFailure)
1252
+ return false;
1250
1253
  const method = err.retry?.method.toUpperCase();
1251
1254
  if (!method || method === "GET" || method === "HEAD")
1252
1255
  return false;
@@ -1363,6 +1366,7 @@ function buildErrorReport(err, contextOverride) {
1363
1366
  idempotency: "none",
1364
1367
  ...dssErrorDetails(err),
1365
1368
  ...dssDiagnosticDetails(err),
1369
+ ...err.details,
1366
1370
  ...(err.retry ? { retry: err.retry, } : {}),
1367
1371
  },
1368
1372
  ...context,
@@ -1370,7 +1374,11 @@ function buildErrorReport(err, contextOverride) {
1370
1374
  }
1371
1375
  if (err instanceof DataikuError) {
1372
1376
  const { dssMessage, dssErrorType, } = dssDiagnosticDetails(err);
1373
- const details = { ...dssErrorDetails(err), ...(dssErrorType ? { dssErrorType, } : {}), };
1377
+ const details = {
1378
+ ...dssErrorDetails(err),
1379
+ ...(dssErrorType ? { dssErrorType, } : {}),
1380
+ ...err.details,
1381
+ };
1374
1382
  // Generic hints name what the command targeted. DSS answers 403 (not
1375
1383
  // 404) for a project that does not exist: "Failed to read project
1376
1384
  // permissions". A 403 about something else keeps the generic hint.
@@ -20,12 +20,18 @@ export interface DataikuErrorTrustedMetadata {
20
20
  elapsedMs?: number;
21
21
  /** Set by the transport when the retained response body is only a bounded prefix. */
22
22
  bodyTruncated?: boolean;
23
+ /**
24
+ * Set by a resource that recognized DSS's answer as a definite failure of the
25
+ * whole operation: nothing took effect, so a non-idempotent mutation is not
26
+ * ambiguous.
27
+ */
28
+ definiteFailure?: boolean;
23
29
  }
24
30
  export declare function canonicalStatusText(status: number): string;
25
31
  export declare class ClientValidationError extends Error {
26
32
  readonly code: StableErrorCode;
27
33
  readonly hint?: string;
28
- readonly details?: Record<string, unknown>;
34
+ details?: Record<string, unknown>;
29
35
  constructor(message: string, code?: StableErrorCode, hint?: string, details?: Record<string, unknown>, options?: ErrorOptions);
30
36
  }
31
37
  /**
@@ -62,6 +68,10 @@ export declare class DataikuError extends Error {
62
68
  readonly trustedTarget?: string;
63
69
  readonly trustedElapsedMs?: number;
64
70
  readonly bodyTruncated?: boolean;
71
+ /** DSS definitively failed the whole operation; see `DataikuErrorTrustedMetadata.definiteFailure`. */
72
+ readonly definiteFailure?: boolean;
73
+ /** Client-side outcome context (e.g. rollback of partial mutations), surfaced in CLI `details`. */
74
+ details?: Record<string, unknown>;
65
75
  constructor(status: number, statusText: string, body: string, retry?: DataikuRetryMetadata, requestId?: string, trustedMetadata?: DataikuErrorTrustedMetadata);
66
76
  /** Status line only: never echoes response body text. */
67
77
  get safeMessage(): string;
@@ -74,6 +74,8 @@ export const UNEXPECTED_RESPONSE_MARKER = "Unexpected DSS response";
74
74
  export function unexpectedResponseError(detail, status = 200) {
75
75
  return new DataikuError(status, "Unexpected Response", `${UNEXPECTED_RESPONSE_MARKER}: ${detail}`);
76
76
  }
77
+ /** Hint for a DSS answer that definitively failed the request (e.g. the database rejected the SQL). */
78
+ const DEFINITE_FAILURE_HINT = "DSS reported that the request failed (for SQL: syntax error, missing object, or missing privileges of the connection's role). Fix the request or grants; do not retry unchanged.";
77
79
  /** Prefix for a SQL query failure reported by DSS on a 2xx finish-streaming response. */
78
80
  export const SQL_QUERY_FAILED_MARKER = "SQL query failed in DSS";
79
81
  const TLS_CERTIFICATE_HINT = "TLS certificate verification failed. Trust the DSS/corporate CA with --ca-cert PATH or NODE_EXTRA_CA_CERTS, or save it for every process and directory with `dss auth login --ca-cert PATH` (a project .env applies only when dss runs in that directory); use --insecure only for temporary troubleshooting.";
@@ -140,6 +142,8 @@ function isCertificateTrustFailure(lowerBody) {
140
142
  || lowerBody.includes("unable to get local issuer certificate")
141
143
  || lowerBody.includes("unable to verify leaf signature")
142
144
  || lowerBody.includes("self-signed certificate")
145
+ || lowerBody.includes("self signed certificate")
146
+ || lowerBody.includes("depth_zero_self_signed_cert")
143
147
  || lowerBody.includes("certificate has expired")
144
148
  || lowerBody.includes("cert_has_expired")
145
149
  || lowerBody.includes("self_signed_cert_in_chain")
@@ -372,6 +376,10 @@ export class DataikuError extends Error {
372
376
  trustedTarget;
373
377
  trustedElapsedMs;
374
378
  bodyTruncated;
379
+ /** DSS definitively failed the whole operation; see `DataikuErrorTrustedMetadata.definiteFailure`. */
380
+ definiteFailure;
381
+ /** Client-side outcome context (e.g. rollback of partial mutations), surfaced in CLI `details`. */
382
+ details;
375
383
  constructor(status, statusText, body, retry, requestId, trustedMetadata) {
376
384
  const details = DataikuError.buildDetails(status, body);
377
385
  super(details.message);
@@ -379,14 +387,21 @@ export class DataikuError extends Error {
379
387
  this.statusText = statusText;
380
388
  this.body = body;
381
389
  this.name = "DataikuError";
382
- this.category = details.category;
383
- this.retryable = details.retryable;
384
- this.retryHint = details.retryHint;
390
+ const definite = trustedMetadata?.definiteFailure === true;
391
+ // A definite DSS refusal is not a transport/server fault: replaying it
392
+ // unchanged cannot succeed. Keep a more specific forbidden/not_found class.
393
+ const reclassify = definite
394
+ && (details.category === "transient" || details.category === "unknown"
395
+ || details.category === "unexpected_response");
396
+ this.category = reclassify ? "validation" : details.category;
397
+ this.retryable = reclassify ? false : details.retryable;
398
+ this.retryHint = reclassify ? DEFINITE_FAILURE_HINT : details.retryHint;
385
399
  this.retry = retry;
386
400
  this.requestId = requestId;
387
401
  this.trustedTarget = trustedMetadata?.target;
388
402
  this.trustedElapsedMs = trustedMetadata?.elapsedMs;
389
403
  this.bodyTruncated = trustedMetadata?.bodyTruncated === true ? true : undefined;
404
+ this.definiteFailure = definite ? true : undefined;
390
405
  }
391
406
  /** Status line only: never echoes response body text. */
392
407
  get safeMessage() {
@@ -4,6 +4,24 @@
4
4
  * public endpoint writes; without it every downstream build fails.
5
5
  */
6
6
  export declare function assertDatasetTypeCreatable(dsType: string): void;
7
+ /**
8
+ * Dataset types that read from a SQL connection. These are the types DSS
9
+ * detects schemas for through `testAndDetectSettings/externalSQL` (the list
10
+ * dataikuapi's `DSSDataset.autodetect_settings` uses).
11
+ */
12
+ export declare const SQL_DATASET_TYPES: readonly ["JDBC", "PostgreSQL", "MySQL", "Vertica", "Snowflake", "Redshift", "Greenplum", "Teradata", "Oracle", "SQLServer", "SAPHANA", "Netezza", "BigQuery", "Athena", "hiveserver2", "Synapse", "FabricWarehouse", "Databricks", "DatabricksLakebase"];
13
+ export declare function isSqlDatasetType(dsType: string): boolean;
14
+ /**
15
+ * Validate the SQL source options (`table`/`dbSchema`/`catalog`/`query`) of a
16
+ * dataset creation against its type. Shared by `dataset create`, its plan, and
17
+ * the SDK so every entry point rejects the same combinations.
18
+ */
19
+ export declare function assertSqlDatasetSource(dsType: string, source: {
20
+ table?: string;
21
+ dbSchema?: string;
22
+ catalog?: string;
23
+ query?: string;
24
+ }): void;
7
25
  /** Wire body for POST /datasets/; shared with `dataset create --plan` so the plan matches the request. */
8
26
  export declare function buildDatasetCreateBody(opts: {
9
27
  projectKey: string;
@@ -13,6 +31,7 @@ export declare function buildDatasetCreateBody(opts: {
13
31
  table?: string;
14
32
  dbSchema?: string;
15
33
  catalog?: string;
34
+ query?: string;
16
35
  formatType?: string;
17
36
  formatParams?: Record<string, unknown>;
18
37
  managed?: boolean;