dataiku-sdk 3.0.0 → 3.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 (109) hide show
  1. package/README.md +246 -19
  2. package/dist/build-metadata.json +1 -1
  3. package/dist/packages/types/src/index.d.ts +39 -4
  4. package/dist/packages/types/src/index.js +31 -2
  5. package/dist/src/auth.js +1 -1
  6. package/dist/src/cli/coerce.js +11 -2
  7. package/dist/src/cli/commands/analysis.js +4 -3
  8. package/dist/src/cli/commands/api-service.js +66 -18
  9. package/dist/src/cli/commands/app.js +4 -3
  10. package/dist/src/cli/commands/bundle.js +51 -16
  11. package/dist/src/cli/commands/code-env.js +219 -57
  12. package/dist/src/cli/commands/code.d.ts +19 -0
  13. package/dist/src/cli/commands/code.js +87 -15
  14. package/dist/src/cli/commands/dashboard.js +7 -6
  15. package/dist/src/cli/commands/data-quality.js +9 -8
  16. package/dist/src/cli/commands/dataset.js +17 -11
  17. package/dist/src/cli/commands/doctor.js +4 -2
  18. package/dist/src/cli/commands/flow-zone.js +42 -13
  19. package/dist/src/cli/commands/folder.js +9 -8
  20. package/dist/src/cli/commands/future.js +2 -1
  21. package/dist/src/cli/commands/insight.js +8 -7
  22. package/dist/src/cli/commands/job.js +4 -3
  23. package/dist/src/cli/commands/meaning.js +5 -4
  24. package/dist/src/cli/commands/ml-task.js +3 -2
  25. package/dist/src/cli/commands/model-evaluation-store.js +4 -3
  26. package/dist/src/cli/commands/notebook.js +82 -50
  27. package/dist/src/cli/commands/project-git.js +8 -5
  28. package/dist/src/cli/commands/project-library.d.ts +21 -0
  29. package/dist/src/cli/commands/project-library.js +128 -56
  30. package/dist/src/cli/commands/project.js +19 -4
  31. package/dist/src/cli/commands/recipe.js +51 -25
  32. package/dist/src/cli/commands/saved-model.js +4 -3
  33. package/dist/src/cli/commands/scenario.js +9 -8
  34. package/dist/src/cli/commands/sql.d.ts +10 -0
  35. package/dist/src/cli/commands/sql.js +42 -19
  36. package/dist/src/cli/commands/statistics.js +7 -6
  37. package/dist/src/cli/commands/variable.js +2 -1
  38. package/dist/src/cli/commands/webapp.js +68 -13
  39. package/dist/src/cli/commands/wiki.js +7 -6
  40. package/dist/src/cli/contract.d.ts +16 -1
  41. package/dist/src/cli/contract.js +967 -186
  42. package/dist/src/cli/env.d.ts +9 -0
  43. package/dist/src/cli/env.js +20 -2
  44. package/dist/src/cli/flags.d.ts +4 -0
  45. package/dist/src/cli/flags.js +29 -1
  46. package/dist/src/cli/helpers/cleanup.js +104 -3
  47. package/dist/src/cli/helpers/flow-zone.d.ts +3 -0
  48. package/dist/src/cli/helpers/flow-zone.js +33 -3
  49. package/dist/src/cli/helpers/recipe.d.ts +20 -0
  50. package/dist/src/cli/helpers/recipe.js +158 -3
  51. package/dist/src/cli/output.d.ts +2 -0
  52. package/dist/src/cli/output.js +33 -6
  53. package/dist/src/cli/runtime.d.ts +35 -1
  54. package/dist/src/cli/runtime.js +125 -18
  55. package/dist/src/cli/types.d.ts +7 -0
  56. package/dist/src/cli.js +226 -95
  57. package/dist/src/client.d.ts +63 -1
  58. package/dist/src/client.js +305 -53
  59. package/dist/src/errors.d.ts +8 -2
  60. package/dist/src/errors.js +42 -0
  61. package/dist/src/index.d.ts +4 -3
  62. package/dist/src/index.js +2 -1
  63. package/dist/src/resources/api-services.d.ts +48 -6
  64. package/dist/src/resources/api-services.js +54 -8
  65. package/dist/src/resources/bundles.d.ts +51 -6
  66. package/dist/src/resources/bundles.js +39 -9
  67. package/dist/src/resources/code-envs.d.ts +25 -3
  68. package/dist/src/resources/code-envs.js +104 -2
  69. package/dist/src/resources/datasets.d.ts +20 -0
  70. package/dist/src/resources/datasets.js +232 -52
  71. package/dist/src/resources/folders.js +2 -1
  72. package/dist/src/resources/jobs.d.ts +35 -10
  73. package/dist/src/resources/jobs.js +92 -17
  74. package/dist/src/resources/notebooks.d.ts +77 -7
  75. package/dist/src/resources/notebooks.js +143 -19
  76. package/dist/src/resources/project-git.d.ts +12 -0
  77. package/dist/src/resources/project-git.js +39 -8
  78. package/dist/src/resources/project-library.d.ts +122 -7
  79. package/dist/src/resources/project-library.js +494 -25
  80. package/dist/src/resources/projects.d.ts +5 -4
  81. package/dist/src/resources/projects.js +21 -19
  82. package/dist/src/resources/scenarios.d.ts +30 -0
  83. package/dist/src/resources/scenarios.js +73 -27
  84. package/dist/src/resources/sql.d.ts +6 -0
  85. package/dist/src/resources/sql.js +23 -3
  86. package/dist/src/resources/webapps.d.ts +35 -4
  87. package/dist/src/resources/webapps.js +54 -5
  88. package/dist/src/schemas.d.ts +2 -2
  89. package/dist/src/schemas.js +1 -1
  90. package/dist/src/skill.d.ts +18 -0
  91. package/dist/src/skill.js +93 -9
  92. package/dist/src/utils/flow-analysis.d.ts +53 -0
  93. package/dist/src/utils/flow-analysis.js +382 -0
  94. package/dist/src/utils/project-archive.d.ts +41 -2
  95. package/dist/src/utils/project-archive.js +159 -16
  96. package/dist/src/utils/response-file.d.ts +2 -0
  97. package/dist/src/utils/response-file.js +34 -0
  98. package/package.json +9 -7
  99. package/packages/types/dist/index.d.ts +39 -4
  100. package/packages/types/dist/index.js +31 -2
  101. package/plugin.json +1 -1
  102. package/skills/dataiku-dss/SKILL.md +44 -197
  103. package/skills/dataiku-dss/references/app-releases.md +27 -0
  104. package/skills/dataiku-dss/references/authentication.md +39 -0
  105. package/skills/dataiku-dss/references/coding.md +8 -0
  106. package/skills/dataiku-dss/references/discovery.md +30 -0
  107. package/skills/dataiku-dss/references/flow-maps.md +12 -0
  108. package/skills/dataiku-dss/references/mutations.md +33 -0
  109. package/skills/dataiku-dss/references/troubleshooting.md +29 -0
@@ -1,5 +1,11 @@
1
- import { CodeEnvSummaryArraySchema, CodeEnvUsageArraySchema, } from "../schemas.js";
1
+ import { ClientValidationError, } from "../errors.js";
2
+ import { CodeEnvLogSummaryArraySchema, CodeEnvSummaryArraySchema, CodeEnvUsageArraySchema, CodeEnvVersionForProjectSchema, } from "../schemas.js";
3
+ import { stableHash, } from "../utils/stable-hash.js";
2
4
  import { BaseResource, } from "./base.js";
5
+ /** Byte cap applied to fetched code-env logs so stdout/JSON stays bounded. */
6
+ const DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024;
7
+ /** Default number of tail lines returned by getLog (0 disables). */
8
+ const DEFAULT_LOG_MAX_LINES = 500;
3
9
  export class CodeEnvsResource extends BaseResource {
4
10
  async list(opts) {
5
11
  const raw = await this.client.get("/public/api/admin/code-envs/");
@@ -21,8 +27,12 @@ export class CodeEnvsResource extends BaseResource {
21
27
  envName: raw.envName ?? envName,
22
28
  envLang: raw.envLang ?? envLang,
23
29
  pythonInterpreter: desc.pythonInterpreter,
30
+ deploymentMode: typeof raw.deploymentMode === "string"
31
+ ? raw.deploymentMode
32
+ : undefined,
24
33
  requestedPackages,
25
34
  installedPackages,
35
+ definitionHash: stableHash(raw),
26
36
  };
27
37
  }
28
38
  async getDefinition(envLang, envName) {
@@ -30,6 +40,61 @@ export class CodeEnvsResource extends BaseResource {
30
40
  const nameEnc = encodeURIComponent(envName);
31
41
  return this.client.get(`/public/api/admin/code-envs/${langEnc}/${nameEnc}`);
32
42
  }
43
+ /** List the build log files DSS keeps for this code environment. */
44
+ async listLogs(envLang, envName) {
45
+ const langEnc = encodeURIComponent(envLang);
46
+ const nameEnc = encodeURIComponent(envName);
47
+ const raw = await this.client.get(`/public/api/admin/code-envs/${langEnc}/${nameEnc}/logs`);
48
+ return this.client.safeParse(CodeEnvLogSummaryArraySchema, raw, "codeEnvs.listLogs");
49
+ }
50
+ /**
51
+ * Fetch one code-env build log, bounded by default: at most
52
+ * `maxBytes` bytes (default 10 MiB, 0 disables the cap) and the last
53
+ * `maxLines` lines (default 500, 0 disables the tail). Returns what was
54
+ * kept plus the truncation flags so callers can warn instead of
55
+ * silently losing output.
56
+ */
57
+ async getLog(envLang, envName, logName, opts) {
58
+ const langEnc = encodeURIComponent(envLang);
59
+ const nameEnc = encodeURIComponent(envName);
60
+ const logEnc = encodeURIComponent(logName);
61
+ const path = `/public/api/admin/code-envs/${langEnc}/${nameEnc}/logs/${logEnc}`;
62
+ const maxBytes = opts?.maxBytes ?? DEFAULT_LOG_MAX_BYTES;
63
+ const fetched = maxBytes > 0
64
+ ? await this.client.getTextLimited(path, maxBytes)
65
+ : { text: await this.client.getText(path), truncated: false, };
66
+ const tailed = tailLog(fetched.text, opts?.maxLines ?? DEFAULT_LOG_MAX_LINES);
67
+ return {
68
+ log: tailed.log,
69
+ bytes: Buffer.byteLength(fetched.text, "utf-8"),
70
+ truncated: fetched.truncated,
71
+ tailed: tailed.tailed,
72
+ };
73
+ }
74
+ /**
75
+ * Rebuild the Docker image(s) of the code environment to match its
76
+ * settings. `wait: false` returns the raw DSS future payload.
77
+ */
78
+ async updateImages(envLang, envName, opts) {
79
+ const langEnc = encodeURIComponent(envLang);
80
+ const nameEnc = encodeURIComponent(envName);
81
+ const query = new URLSearchParams();
82
+ if (opts?.envVersion !== undefined)
83
+ query.set("envVersion", opts.envVersion);
84
+ query.set("wait", String(opts?.wait !== false));
85
+ return this.client.post(`/public/api/admin/code-envs/${langEnc}/${nameEnc}/images?${query.toString()}`);
86
+ }
87
+ /**
88
+ * Resolve the code-env version a project uses (versioned environments on
89
+ * automation nodes); empty `version` for unversioned environments.
90
+ */
91
+ async getVersionForProject(envLang, envName, projectKey) {
92
+ const langEnc = encodeURIComponent(envLang);
93
+ const nameEnc = encodeURIComponent(envName);
94
+ const pkEnc = encodeURIComponent(projectKey);
95
+ const raw = await this.client.get(`/public/api/admin/code-envs/${langEnc}/${nameEnc}/${pkEnc}/version`);
96
+ return this.client.safeParse(CodeEnvVersionForProjectSchema, raw, "codeEnvs.getVersionForProject");
97
+ }
33
98
  async create(opts) {
34
99
  const langEnc = encodeURIComponent(opts.envLang);
35
100
  const nameEnc = encodeURIComponent(opts.envName);
@@ -39,13 +104,20 @@ export class CodeEnvsResource extends BaseResource {
39
104
  };
40
105
  return this.client.post(`/public/api/admin/code-envs/${langEnc}/${nameEnc}?wait=${opts.wait !== false}`, body);
41
106
  }
42
- async setDefinition(envLang, envName, definition) {
107
+ async setDefinition(envLang, envName, definition, opts) {
108
+ if (opts?.expectHash !== undefined) {
109
+ const current = await this.getDefinition(envLang, envName);
110
+ assertDefinitionHash(envLang, envName, current, opts.expectHash);
111
+ }
43
112
  const langEnc = encodeURIComponent(envLang);
44
113
  const nameEnc = encodeURIComponent(envName);
45
114
  return this.client.put(`/public/api/admin/code-envs/${langEnc}/${nameEnc}`, definition);
46
115
  }
47
116
  async setPackages(envLang, envName, packages, opts) {
48
117
  const current = await this.getDefinition(envLang, envName);
118
+ if (opts?.expectHash !== undefined) {
119
+ assertDefinitionHash(envLang, envName, current, opts.expectHash);
120
+ }
49
121
  const next = {
50
122
  ...current,
51
123
  specPackageList: normalizePackageList(packages),
@@ -98,6 +170,36 @@ export class CodeEnvsResource extends BaseResource {
98
170
  return this.client.safeParse(CodeEnvUsageArraySchema, raw, "codeEnvs.listUsages");
99
171
  }
100
172
  }
173
+ /**
174
+ * Keep only the last `maxLines` lines of a log (0 disables the tail).
175
+ * Returns whether any leading lines were dropped.
176
+ */
177
+ function tailLog(log, maxLines) {
178
+ if (!log)
179
+ return { log, tailed: false, };
180
+ if (maxLines <= 0)
181
+ return { log, tailed: false, };
182
+ const lines = log.split(/\r\n|\n/);
183
+ if (lines.length <= maxLines)
184
+ return { log, tailed: false, };
185
+ return { log: lines.slice(lines.length - maxLines).join("\n"), tailed: true, };
186
+ }
187
+ /**
188
+ * Provenance guard for full-definition writes: DSS requires the PUT body to
189
+ * come from a prior GET, so refuse the write when the definition no longer
190
+ * hashes to the hash the caller captured.
191
+ */
192
+ function assertDefinitionHash(envLang, envName, current, expected) {
193
+ const currentHash = stableHash(current);
194
+ if (currentHash === expected)
195
+ return;
196
+ throw new ClientValidationError(`Code env ${envLang}/${envName} changed since the expected definition hash was captured; refusing to overwrite it.`, "validation_failed", "Re-read the definition with dss code-env get-definition (definitionHash comes from code-env get), review the diff, and retry with the fresh hash.", {
197
+ envLang,
198
+ envName,
199
+ expectedDefinitionHash: expected,
200
+ currentDefinitionHash: currentHash,
201
+ });
202
+ }
101
203
  function splitPackageList(raw) {
102
204
  if (!raw)
103
205
  return [];
@@ -80,6 +80,10 @@ export declare class DatasetsResource extends BaseResource {
80
80
  *
81
81
  * If `validateColumns` is provided, the first TSV row (header) is checked
82
82
  * against the column names. Mismatches emit a warning via onValidationWarning.
83
+ *
84
+ * The stream is byte-bounded (16 MiB per field, 64 MiB per row, 16 GiB per
85
+ * response): an oversized field or unterminated quoted row aborts the
86
+ * preview, cancels the response, and fails with a DataikuError.
83
87
  */
84
88
  preview(datasetName: string, opts?: {
85
89
  maxRows?: number;
@@ -103,6 +107,10 @@ export declare class DatasetsResource extends BaseResource {
103
107
  * fully scanned. Returns the observed count together with `exact` (false when
104
108
  * the probe cap was reached, i.e. the count is a lower bound) and whether the
105
109
  * count satisfies the assertion.
110
+ *
111
+ * Streaming is byte-bounded (16 MiB per field, 64 MiB per row, 16 GiB per
112
+ * response); oversized fields abort the count, cancel the response, and
113
+ * fail with a DataikuError.
106
114
  */
107
115
  assertRowCount(datasetName: string, expected: number, projectKey?: string): Promise<{
108
116
  expected: number;
@@ -117,6 +125,16 @@ export declare class DatasetsResource extends BaseResource {
117
125
  * (default 100k). Returns the written path, the number of data rows written,
118
126
  * whether the dataset had more rows than the cap (truncated), and the limit
119
127
  * used — so callers can detect truncation instead of silently getting a sample.
128
+ *
129
+ * Exported cells are spreadsheet-safe by default: headers/values whose
130
+ * first effective character is a spreadsheet formula sigil (`=`, `+`, `-`,
131
+ * `@`, including leading whitespace/control variants) are neutralized with
132
+ * a leading apostrophe so the file never executes formulas on open. Pass
133
+ * `rawData: true` to preserve exact bytes.
134
+ *
135
+ * Streaming is byte-bounded (16 MiB per field, 64 MiB per row, 16 GiB per
136
+ * response); on overflow the response is cancelled, the error is thrown,
137
+ * and any partially written download file is removed.
120
138
  */
121
139
  download(datasetName: string, opts?: {
122
140
  outputPath?: string;
@@ -125,6 +143,8 @@ export declare class DatasetsResource extends BaseResource {
125
143
  name: string;
126
144
  }[];
127
145
  limit?: number;
146
+ /** Preserve exact cell bytes (no spreadsheet-safe formula neutralization), default false. */
147
+ rawData?: boolean;
128
148
  }): Promise<{
129
149
  path: string;
130
150
  rows: number;
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as nodePath from "node:path";
3
3
  import { Readable, Transform, } from "node:stream";
4
4
  import { pipeline, } from "node:stream/promises";
5
+ import { StringDecoder, } from "node:string_decoder";
5
6
  import { createGzip, } from "node:zlib";
6
7
  import { ClientValidationError, DataikuError, } from "../errors.js";
7
8
  import { DatasetDetailsSchema, DatasetSchemaSchema, DatasetSummaryArraySchema, } from "../schemas.js";
@@ -12,7 +13,52 @@ import { resolveAdminManagedStorageConnection, } from "./connections.js";
12
13
  // ---------------------------------------------------------------------------
13
14
  // Helpers: TSV → CSV streaming conversion
14
15
  // ---------------------------------------------------------------------------
15
- function csvEscape(field) {
16
+ /**
17
+ * Decoded-byte budgets for the streaming TSV parser. Each budget fails closed
18
+ * — the response is cancelled and any partial download removed — so a dataset
19
+ * holding one oversized field, an unterminated quoted row, or a huge response
20
+ * can never accumulate unbounded memory or CPU before row limits apply.
21
+ */
22
+ const TSV_MAX_FIELD_BYTES = 16 * 1024 * 1024; // 16 MiB per decoded field
23
+ const TSV_MAX_ROW_BYTES = 64 * 1024 * 1024; // 64 MiB per decoded row
24
+ const TSV_MAX_RESPONSE_BYTES = 16 * 1024 * 1024 * 1024; // 16 GiB per decoded response
25
+ function formatLimitBytes(bytes) {
26
+ if (bytes >= 1024 * 1024 * 1024)
27
+ return `${bytes / (1024 * 1024 * 1024)} GiB`;
28
+ if (bytes >= 1024 * 1024)
29
+ return `${bytes / (1024 * 1024)} MiB`;
30
+ return `${bytes} B`;
31
+ }
32
+ function tsvLimitError(what, limit) {
33
+ return new DataikuError(0, "Dataset TSV Limit", `Dataset TSV ${what} exceeded ${formatLimitBytes(limit)}; streaming was aborted to guard against unbounded memory use.`);
34
+ }
35
+ /** Characters that evaluate a spreadsheet cell as a formula. */
36
+ const FORMULA_SIGILS = "=+-@";
37
+ /**
38
+ * True when the first effective character of `field` — the first character
39
+ * that is not leading whitespace/control — is a spreadsheet formula sigil.
40
+ * Spreadsheet applications evaluate such cells even when CSV-quoted, so an
41
+ * untrusted dataset producer could plant formulas that execute on open.
42
+ */
43
+ function hasFormulaPrefix(field) {
44
+ for (let i = 0; i < field.length; i++) {
45
+ const code = field.charCodeAt(i);
46
+ if (code <= 0x20 || (code >= 0x7f && code <= 0x9f))
47
+ continue;
48
+ return FORMULA_SIGILS.includes(field[i]);
49
+ }
50
+ return false;
51
+ }
52
+ /**
53
+ * Escape one TSV field for CSV output. In spreadsheet-safe mode (the default
54
+ * for dataset downloads) a field whose first effective character is a formula
55
+ * sigil gets a leading apostrophe so spreadsheet importers show text instead
56
+ * of executing it; `rawData: true` (CLI `--raw-data`) preserves exact bytes.
57
+ */
58
+ function csvEscape(field, spreadsheetSafe) {
59
+ if (spreadsheetSafe && hasFormulaPrefix(field)) {
60
+ field = `'${field}`;
61
+ }
16
62
  if (field.includes(",")
17
63
  || field.includes('"')
18
64
  || field.includes("\n")
@@ -22,21 +68,56 @@ function csvEscape(field) {
22
68
  }
23
69
  return field;
24
70
  }
25
- function createTsvStreamState() {
71
+ function createTsvStreamState(limits = {
72
+ maxFieldBytes: TSV_MAX_FIELD_BYTES,
73
+ maxRowBytes: TSV_MAX_ROW_BYTES,
74
+ maxResponseBytes: TSV_MAX_RESPONSE_BYTES,
75
+ }) {
26
76
  return {
27
77
  currentField: "",
28
78
  currentRow: [],
29
79
  inQuotes: false,
30
80
  pendingQuoteInQuotes: false,
81
+ fieldBytes: 0,
82
+ rowBytes: 0,
83
+ responseBytes: 0,
84
+ limits,
31
85
  };
32
86
  }
87
+ /** Append decoded field data, bounding field/row bytes and failing closed. */
88
+ function appendFieldText(state, text) {
89
+ if (text.length === 0)
90
+ return;
91
+ const bytes = Buffer.byteLength(text, "utf-8");
92
+ state.fieldBytes += bytes;
93
+ state.rowBytes += bytes;
94
+ if (state.fieldBytes > state.limits.maxFieldBytes) {
95
+ throw tsvLimitError("field", state.limits.maxFieldBytes);
96
+ }
97
+ if (state.rowBytes > state.limits.maxRowBytes) {
98
+ throw tsvLimitError("row", state.limits.maxRowBytes);
99
+ }
100
+ state.currentField += text;
101
+ }
102
+ /** Close a field at a tab/newline delimiter and reset the field byte count. */
103
+ function endField(state) {
104
+ state.currentRow.push(state.currentField);
105
+ state.currentField = "";
106
+ state.fieldBytes = 0;
107
+ state.rowBytes += 1; // count the delimiter byte
108
+ }
33
109
  function consumeTsvChunk(text, state, onRow) {
110
+ // Response budget: bound total decoded bytes regardless of row structure.
111
+ state.responseBytes += Buffer.byteLength(text, "utf-8");
112
+ if (state.responseBytes > state.limits.maxResponseBytes) {
113
+ throw tsvLimitError("response", state.limits.maxResponseBytes);
114
+ }
34
115
  let i = 0;
35
116
  if (state.pendingQuoteInQuotes) {
36
117
  state.pendingQuoteInQuotes = false;
37
118
  const first = text[0];
38
119
  if (first === '"') {
39
- state.currentField += '"';
120
+ appendFieldText(state, '"');
40
121
  i = 1;
41
122
  }
42
123
  else if (first === "\t" || first === "\n" || first === "\r") {
@@ -44,72 +125,102 @@ function consumeTsvChunk(text, state, onRow) {
44
125
  }
45
126
  else if (first !== undefined) {
46
127
  // Ambiguous terminal quote from previous chunk; keep it as data.
47
- state.currentField += '"';
128
+ appendFieldText(state, '"');
48
129
  }
49
130
  }
50
- for (; i < text.length; i++) {
51
- const ch = text[i];
131
+ while (i < text.length) {
52
132
  if (state.inQuotes) {
53
- if (ch === '"') {
54
- const next = text[i + 1];
55
- if (next === '"') {
56
- state.currentField += '"';
57
- i++;
58
- continue;
59
- }
60
- if (next === undefined) {
61
- state.pendingQuoteInQuotes = true;
62
- continue;
63
- }
64
- if (next === "\t" || next === "\n" || next === "\r") {
65
- state.inQuotes = false;
66
- continue;
67
- }
68
- // Quote in the middle of quoted field text — keep it literal.
69
- state.currentField += '"';
133
+ const quoteIdx = text.indexOf('"', i);
134
+ if (quoteIdx === -1) {
135
+ appendFieldText(state, text.slice(i));
136
+ break;
137
+ }
138
+ if (quoteIdx > i)
139
+ appendFieldText(state, text.slice(i, quoteIdx));
140
+ const next = text[quoteIdx + 1];
141
+ if (next === '"') {
142
+ appendFieldText(state, '"');
143
+ i = quoteIdx + 2;
144
+ continue;
145
+ }
146
+ if (next === undefined) {
147
+ state.pendingQuoteInQuotes = true;
148
+ break;
149
+ }
150
+ if (next === "\t" || next === "\n" || next === "\r") {
151
+ state.inQuotes = false;
152
+ i = quoteIdx + 1;
70
153
  continue;
71
154
  }
72
- state.currentField += ch;
155
+ // Quote in the middle of quoted field text — keep it literal.
156
+ appendFieldText(state, '"');
157
+ i = quoteIdx + 1;
73
158
  continue;
74
159
  }
75
- if (ch === '"' && state.currentField.length === 0) {
76
- state.inQuotes = true;
160
+ // Scan ahead in whole runs to the next structural character, so field
161
+ // data is appended in slices instead of one character at a time.
162
+ let special = -1;
163
+ for (let j = i; j < text.length; j++) {
164
+ const ch = text[j];
165
+ if (ch === '"' || ch === "\t" || ch === "\n" || ch === "\r") {
166
+ special = j;
167
+ break;
168
+ }
169
+ }
170
+ const runEnd = special === -1 ? text.length : special;
171
+ if (runEnd > i) {
172
+ appendFieldText(state, text.slice(i, runEnd));
173
+ i = runEnd;
174
+ }
175
+ if (special === -1)
176
+ break;
177
+ const ch = text[special];
178
+ if (ch === '"') {
179
+ if (state.currentField.length === 0) {
180
+ state.inQuotes = true;
181
+ }
182
+ else {
183
+ // Quote in the middle of unquoted field text — keep it literal.
184
+ appendFieldText(state, '"');
185
+ }
186
+ i = special + 1;
77
187
  continue;
78
188
  }
79
189
  if (ch === "\t") {
80
- state.currentRow.push(state.currentField);
81
- state.currentField = "";
190
+ endField(state);
191
+ i = special + 1;
82
192
  continue;
83
193
  }
84
194
  if (ch === "\n") {
85
- state.currentRow.push(state.currentField);
86
- state.currentField = "";
195
+ endField(state);
196
+ i = special + 1;
87
197
  const row = state.currentRow;
88
198
  state.currentRow = [];
199
+ state.rowBytes = 0;
89
200
  onRow(row);
90
201
  continue;
91
202
  }
92
- if (ch === "\r") {
93
- continue;
94
- }
95
- state.currentField += ch;
203
+ // "\r" — carriage return: skipped, as in the original parser.
204
+ i = special + 1;
96
205
  }
97
206
  }
98
207
  function flushTsvStream(state, onRow) {
99
208
  if (state.pendingQuoteInQuotes) {
100
- state.currentField += '"';
209
+ appendFieldText(state, '"');
101
210
  state.pendingQuoteInQuotes = false;
102
211
  }
103
212
  if (state.currentField.length === 0 && state.currentRow.length === 0)
104
213
  return;
105
214
  state.currentRow.push(state.currentField);
106
215
  state.currentField = "";
216
+ state.fieldBytes = 0;
107
217
  const row = state.currentRow;
108
218
  state.currentRow = [];
219
+ state.rowBytes = 0;
109
220
  onRow(row);
110
221
  }
111
- function rowToCsv(row) {
112
- return row.map((field) => csvEscape(field)).join(",");
222
+ function rowToCsv(row, spreadsheetSafe) {
223
+ return row.map((field) => csvEscape(field, spreadsheetSafe)).join(",");
113
224
  }
114
225
  function isBlankRow(row) {
115
226
  return row.length === 1 && row[0].length === 0;
@@ -154,6 +265,7 @@ async function readChunkWithTimeout(reader, remainingMs, timeoutMs, action) {
154
265
  }
155
266
  async function collectPreviewRows(body, maxDataRows, timeoutMs, onHeader) {
156
267
  const state = createTsvStreamState();
268
+ const decoder = new StringDecoder("utf-8");
157
269
  let columns;
158
270
  const rows = [];
159
271
  let done = false;
@@ -193,12 +305,22 @@ async function collectPreviewRows(body, maxDataRows, timeoutMs, onHeader) {
193
305
  const result = await readChunkWithTimeout(reader, remainingMs, timeoutMs, "preview");
194
306
  if (result.done)
195
307
  break;
196
- consumeTsvChunk(Buffer.from(result.value).toString("utf-8"), state, handleRow);
308
+ consumeTsvChunk(decoder.write(result.value), state, handleRow);
197
309
  }
198
- if (!done)
310
+ if (!done) {
311
+ const tail = decoder.end();
312
+ if (tail !== "")
313
+ consumeTsvChunk(tail, state, handleRow);
199
314
  flushTsvStream(state, handleRow);
315
+ }
200
316
  return { columns: columns ?? [], rows, truncated, };
201
317
  }
318
+ catch (error) {
319
+ // Fail closed on TSV byte-limit overflow: cancel the response instead
320
+ // of retaining partial decoded data.
321
+ void reader.cancel().catch(() => { });
322
+ throw error;
323
+ }
202
324
  finally {
203
325
  reader.releaseLock();
204
326
  }
@@ -211,6 +333,7 @@ async function collectPreviewRows(body, maxDataRows, timeoutMs, onHeader) {
211
333
  */
212
334
  async function collectTsvRowCount(body, stopAfter, timeoutMs) {
213
335
  const state = createTsvStreamState();
336
+ const decoder = new StringDecoder("utf-8");
214
337
  let headerSeen = false;
215
338
  let count = 0;
216
339
  let bounded = false;
@@ -251,18 +374,29 @@ async function collectTsvRowCount(body, stopAfter, timeoutMs) {
251
374
  const result = await readChunkWithTimeout(reader, remainingMs, timeoutMs, "row count assertion");
252
375
  if (result.done)
253
376
  break;
254
- consumeTsvChunk(Buffer.from(result.value).toString("utf-8"), state, handleRow);
377
+ consumeTsvChunk(decoder.write(result.value), state, handleRow);
255
378
  }
256
- if (!bounded)
379
+ if (!bounded) {
380
+ const tail = decoder.end();
381
+ if (tail !== "")
382
+ consumeTsvChunk(tail, state, handleRow);
257
383
  flushTsvStream(state, handleRow);
384
+ }
258
385
  return { count, bounded, };
259
386
  }
387
+ catch (error) {
388
+ // Fail closed on TSV byte-limit overflow: cancel the response instead
389
+ // of continuing to consume unbounded decoded data.
390
+ void reader.cancel().catch(() => { });
391
+ throw error;
392
+ }
260
393
  finally {
261
394
  reader.releaseLock();
262
395
  }
263
396
  }
264
- function tsvToCsvTransform(maxDataRows, stats, onHeader) {
397
+ function tsvToCsvTransform(maxDataRows, stats, onHeader, spreadsheetSafe = true) {
265
398
  const state = createTsvStreamState();
399
+ const decoder = new StringDecoder("utf-8");
266
400
  const maxRows = Math.max(1, maxDataRows);
267
401
  let headerSeen = false;
268
402
  let done = false;
@@ -276,7 +410,7 @@ function tsvToCsvTransform(maxDataRows, stats, onHeader) {
276
410
  headerSeen = true;
277
411
  columnCount = row.length;
278
412
  onHeader?.(row);
279
- push(`${rowToCsv(row)}\n`);
413
+ push(`${rowToCsv(row, spreadsheetSafe)}\n`);
280
414
  return;
281
415
  }
282
416
  if (isBlankRow(row) && columnCount !== 1)
@@ -286,7 +420,7 @@ function tsvToCsvTransform(maxDataRows, stats, onHeader) {
286
420
  done = true;
287
421
  return;
288
422
  }
289
- push(`${rowToCsv(row)}\n`);
423
+ push(`${rowToCsv(row, spreadsheetSafe)}\n`);
290
424
  stats.rows += 1;
291
425
  };
292
426
  return new Transform({
@@ -295,15 +429,35 @@ function tsvToCsvTransform(maxDataRows, stats, onHeader) {
295
429
  callback();
296
430
  return;
297
431
  }
298
- consumeTsvChunk(chunk.toString("utf-8"), state, (row) => handleRow(row, (line) => this.push(line)));
432
+ try {
433
+ consumeTsvChunk(decoder.write(chunk), state, (row) => handleRow(row, (line) => this.push(line)));
434
+ }
435
+ catch (error) {
436
+ // Byte-limit overflow: destroy the transform so the pipeline
437
+ // cancels the response and the partial file is removed.
438
+ callback(error instanceof Error ? error : new Error(String(error)));
439
+ return;
440
+ }
299
441
  if (done)
300
442
  this.push(null);
301
443
  callback();
302
444
  },
303
445
  flush(callback) {
304
- if (!done) {
446
+ if (done) {
447
+ callback();
448
+ return;
449
+ }
450
+ try {
451
+ const tail = decoder.end();
452
+ if (tail !== "") {
453
+ consumeTsvChunk(tail, state, (row) => handleRow(row, (line) => this.push(line)));
454
+ }
305
455
  flushTsvStream(state, (row) => handleRow(row, (line) => this.push(line)));
306
456
  }
457
+ catch (error) {
458
+ callback(error instanceof Error ? error : new Error(String(error)));
459
+ return;
460
+ }
307
461
  callback();
308
462
  },
309
463
  });
@@ -495,6 +649,10 @@ export class DatasetsResource extends BaseResource {
495
649
  *
496
650
  * If `validateColumns` is provided, the first TSV row (header) is checked
497
651
  * against the column names. Mismatches emit a warning via onValidationWarning.
652
+ *
653
+ * The stream is byte-bounded (16 MiB per field, 64 MiB per row, 16 GiB per
654
+ * response): an oversized field or unterminated quoted row aborts the
655
+ * preview, cancels the response, and fails with a DataikuError.
498
656
  */
499
657
  async preview(datasetName, opts) {
500
658
  const maxRows = Math.max(1, Math.min(opts?.maxRows ?? 50, 500));
@@ -529,6 +687,10 @@ export class DatasetsResource extends BaseResource {
529
687
  * fully scanned. Returns the observed count together with `exact` (false when
530
688
  * the probe cap was reached, i.e. the count is a lower bound) and whether the
531
689
  * count satisfies the assertion.
690
+ *
691
+ * Streaming is byte-bounded (16 MiB per field, 64 MiB per row, 16 GiB per
692
+ * response); oversized fields abort the count, cancel the response, and
693
+ * fail with a DataikuError.
532
694
  */
533
695
  async assertRowCount(datasetName, expected, projectKey) {
534
696
  if (!Number.isSafeInteger(expected) || expected < 0) {
@@ -555,6 +717,16 @@ export class DatasetsResource extends BaseResource {
555
717
  * (default 100k). Returns the written path, the number of data rows written,
556
718
  * whether the dataset had more rows than the cap (truncated), and the limit
557
719
  * used — so callers can detect truncation instead of silently getting a sample.
720
+ *
721
+ * Exported cells are spreadsheet-safe by default: headers/values whose
722
+ * first effective character is a spreadsheet formula sigil (`=`, `+`, `-`,
723
+ * `@`, including leading whitespace/control variants) are neutralized with
724
+ * a leading apostrophe so the file never executes formulas on open. Pass
725
+ * `rawData: true` to preserve exact bytes.
726
+ *
727
+ * Streaming is byte-bounded (16 MiB per field, 64 MiB per row, 16 GiB per
728
+ * response); on overflow the response is cancelled, the error is thrown,
729
+ * and any partially written download file is removed.
558
730
  */
559
731
  async download(datasetName, opts) {
560
732
  const limit = Math.max(1, opts?.limit ?? 100_000);
@@ -575,15 +747,23 @@ export class DatasetsResource extends BaseResource {
575
747
  const shouldGzip = filePath.endsWith(".gz");
576
748
  const stats = { rows: 0, truncated: false, };
577
749
  const nodeStream = Readable.fromWeb(res.body);
578
- const csvTransform = tsvToCsvTransform(limit, stats, onHeader);
750
+ const csvTransform = tsvToCsvTransform(limit, stats, onHeader, opts?.rawData !== true);
579
751
  fs.mkdirSync(nodePath.dirname(filePath), { recursive: true, });
580
752
  const fileOut = fs.createWriteStream(filePath);
581
- if (shouldGzip) {
582
- const gzip = createGzip();
583
- await pipeline(nodeStream, csvTransform, gzip, fileOut);
753
+ try {
754
+ if (shouldGzip) {
755
+ const gzip = createGzip();
756
+ await pipeline(nodeStream, csvTransform, gzip, fileOut);
757
+ }
758
+ else {
759
+ await pipeline(nodeStream, csvTransform, fileOut);
760
+ }
584
761
  }
585
- else {
586
- await pipeline(nodeStream, csvTransform, fileOut);
762
+ catch (error) {
763
+ // A failed export (byte-limit overflow, network error) must never
764
+ // leave a partial file that looks like a complete download.
765
+ fs.rmSync(filePath, { force: true, });
766
+ throw error;
587
767
  }
588
768
  return { path: filePath, rows: stats.rows, truncated: stats.truncated, limit, };
589
769
  }
@@ -1,6 +1,7 @@
1
1
  import * as nodePath from "node:path";
2
2
  import { FolderDetailsSchema, FolderItemArraySchema, FolderSummaryArraySchema, } from "../schemas.js";
3
3
  import { deepMerge, } from "../utils/deep-merge.js";
4
+ import { writeResponseToFile, } from "../utils/response-file.js";
4
5
  import { sanitizeFileName, } from "../utils/sanitize.js";
5
6
  import { BaseResource, } from "./base.js";
6
7
  import { resolveAdminManagedStorageConnection, } from "./connections.js";
@@ -73,7 +74,7 @@ export class FoldersResource extends BaseResource {
73
74
  throw new Error("folders.download response did not include a body");
74
75
  const dest = opts?.localPath
75
76
  ?? nodePath.resolve(process.cwd(), inferDownloadFileName(normalizedPath));
76
- await Bun.write(dest, new Response(res.body), { createPath: false, });
77
+ await writeResponseToFile(dest, res);
77
78
  return dest;
78
79
  }
79
80
  upload(folderId, path, localPath, projectKey) {