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.
- package/README.md +246 -19
- package/dist/build-metadata.json +1 -1
- package/dist/packages/types/src/index.d.ts +39 -4
- package/dist/packages/types/src/index.js +31 -2
- package/dist/src/auth.js +1 -1
- package/dist/src/cli/coerce.js +11 -2
- package/dist/src/cli/commands/analysis.js +4 -3
- package/dist/src/cli/commands/api-service.js +66 -18
- package/dist/src/cli/commands/app.js +4 -3
- package/dist/src/cli/commands/bundle.js +51 -16
- package/dist/src/cli/commands/code-env.js +219 -57
- package/dist/src/cli/commands/code.d.ts +19 -0
- package/dist/src/cli/commands/code.js +87 -15
- package/dist/src/cli/commands/dashboard.js +7 -6
- package/dist/src/cli/commands/data-quality.js +9 -8
- package/dist/src/cli/commands/dataset.js +17 -11
- package/dist/src/cli/commands/doctor.js +4 -2
- package/dist/src/cli/commands/flow-zone.js +42 -13
- package/dist/src/cli/commands/folder.js +9 -8
- package/dist/src/cli/commands/future.js +2 -1
- package/dist/src/cli/commands/insight.js +8 -7
- package/dist/src/cli/commands/job.js +4 -3
- package/dist/src/cli/commands/meaning.js +5 -4
- package/dist/src/cli/commands/ml-task.js +3 -2
- package/dist/src/cli/commands/model-evaluation-store.js +4 -3
- package/dist/src/cli/commands/notebook.js +82 -50
- package/dist/src/cli/commands/project-git.js +8 -5
- package/dist/src/cli/commands/project-library.d.ts +21 -0
- package/dist/src/cli/commands/project-library.js +128 -56
- package/dist/src/cli/commands/project.js +19 -4
- package/dist/src/cli/commands/recipe.js +51 -25
- package/dist/src/cli/commands/saved-model.js +4 -3
- package/dist/src/cli/commands/scenario.js +9 -8
- package/dist/src/cli/commands/sql.d.ts +10 -0
- package/dist/src/cli/commands/sql.js +42 -19
- package/dist/src/cli/commands/statistics.js +7 -6
- package/dist/src/cli/commands/variable.js +2 -1
- package/dist/src/cli/commands/webapp.js +68 -13
- package/dist/src/cli/commands/wiki.js +7 -6
- package/dist/src/cli/contract.d.ts +16 -1
- package/dist/src/cli/contract.js +967 -186
- package/dist/src/cli/env.d.ts +9 -0
- package/dist/src/cli/env.js +20 -2
- package/dist/src/cli/flags.d.ts +4 -0
- package/dist/src/cli/flags.js +29 -1
- package/dist/src/cli/helpers/cleanup.js +104 -3
- package/dist/src/cli/helpers/flow-zone.d.ts +3 -0
- package/dist/src/cli/helpers/flow-zone.js +33 -3
- package/dist/src/cli/helpers/recipe.d.ts +20 -0
- package/dist/src/cli/helpers/recipe.js +158 -3
- package/dist/src/cli/output.d.ts +2 -0
- package/dist/src/cli/output.js +33 -6
- package/dist/src/cli/runtime.d.ts +35 -1
- package/dist/src/cli/runtime.js +125 -18
- package/dist/src/cli/types.d.ts +7 -0
- package/dist/src/cli.js +226 -95
- package/dist/src/client.d.ts +63 -1
- package/dist/src/client.js +305 -53
- package/dist/src/errors.d.ts +8 -2
- package/dist/src/errors.js +42 -0
- package/dist/src/index.d.ts +4 -3
- package/dist/src/index.js +2 -1
- package/dist/src/resources/api-services.d.ts +48 -6
- package/dist/src/resources/api-services.js +54 -8
- package/dist/src/resources/bundles.d.ts +51 -6
- package/dist/src/resources/bundles.js +39 -9
- package/dist/src/resources/code-envs.d.ts +25 -3
- package/dist/src/resources/code-envs.js +104 -2
- package/dist/src/resources/datasets.d.ts +20 -0
- package/dist/src/resources/datasets.js +232 -52
- package/dist/src/resources/folders.js +2 -1
- package/dist/src/resources/jobs.d.ts +35 -10
- package/dist/src/resources/jobs.js +92 -17
- package/dist/src/resources/notebooks.d.ts +77 -7
- package/dist/src/resources/notebooks.js +143 -19
- package/dist/src/resources/project-git.d.ts +12 -0
- package/dist/src/resources/project-git.js +39 -8
- package/dist/src/resources/project-library.d.ts +122 -7
- package/dist/src/resources/project-library.js +494 -25
- package/dist/src/resources/projects.d.ts +5 -4
- package/dist/src/resources/projects.js +21 -19
- package/dist/src/resources/scenarios.d.ts +30 -0
- package/dist/src/resources/scenarios.js +73 -27
- package/dist/src/resources/sql.d.ts +6 -0
- package/dist/src/resources/sql.js +23 -3
- package/dist/src/resources/webapps.d.ts +35 -4
- package/dist/src/resources/webapps.js +54 -5
- package/dist/src/schemas.d.ts +2 -2
- package/dist/src/schemas.js +1 -1
- package/dist/src/skill.d.ts +18 -0
- package/dist/src/skill.js +93 -9
- package/dist/src/utils/flow-analysis.d.ts +53 -0
- package/dist/src/utils/flow-analysis.js +382 -0
- package/dist/src/utils/project-archive.d.ts +41 -2
- package/dist/src/utils/project-archive.js +159 -16
- package/dist/src/utils/response-file.d.ts +2 -0
- package/dist/src/utils/response-file.js +34 -0
- package/package.json +9 -7
- package/packages/types/dist/index.d.ts +39 -4
- package/packages/types/dist/index.js +31 -2
- package/plugin.json +1 -1
- package/skills/dataiku-dss/SKILL.md +44 -197
- package/skills/dataiku-dss/references/app-releases.md +27 -0
- package/skills/dataiku-dss/references/authentication.md +39 -0
- package/skills/dataiku-dss/references/coding.md +8 -0
- package/skills/dataiku-dss/references/discovery.md +30 -0
- package/skills/dataiku-dss/references/flow-maps.md +12 -0
- package/skills/dataiku-dss/references/mutations.md +33 -0
- package/skills/dataiku-dss/references/troubleshooting.md +29 -0
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
128
|
+
appendFieldText(state, '"');
|
|
48
129
|
}
|
|
49
130
|
}
|
|
50
|
-
|
|
51
|
-
const ch = text[i];
|
|
131
|
+
while (i < text.length) {
|
|
52
132
|
if (state.inQuotes) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
190
|
+
endField(state);
|
|
191
|
+
i = special + 1;
|
|
82
192
|
continue;
|
|
83
193
|
}
|
|
84
194
|
if (ch === "\n") {
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
|
77
|
+
await writeResponseToFile(dest, res);
|
|
77
78
|
return dest;
|
|
78
79
|
}
|
|
79
80
|
upload(folderId, path, localPath, projectKey) {
|