orizu 0.1.0 → 0.1.1
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/dist/index.js +156 -33
- package/dist/jsonl-stream.js +91 -0
- package/package.json +1 -1
- package/src/index.ts +174 -21
- package/src/jsonl-stream.ts +131 -0
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createHash, randomBytes } from 'crypto';
|
|
3
|
-
import { basename } from 'path';
|
|
3
|
+
import { basename, extname } from 'path';
|
|
4
4
|
import { createServer } from 'http';
|
|
5
5
|
import { readFileSync, statSync, writeFileSync } from 'fs';
|
|
6
6
|
import { spawn } from 'child_process';
|
|
@@ -8,8 +8,8 @@ import { createInterface } from 'readline/promises';
|
|
|
8
8
|
import { stdin as input, stdout as output } from 'process';
|
|
9
9
|
import { clearServerCredentials, getServerCredentials, saveServerCredentials } from './credentials.js';
|
|
10
10
|
import { parseDatasetFile } from './file-parser.js';
|
|
11
|
+
import { streamJsonlRowChunks } from './jsonl-stream.js';
|
|
11
12
|
import { parseDatasetReference } from './dataset-download.js';
|
|
12
|
-
import { extractErrorMessage } from './error-response.js';
|
|
13
13
|
import { parseGlobalFlags } from './global-flags.js';
|
|
14
14
|
import { authedFetch, getBaseUrl, resolveLoginBaseUrl, setGlobalFlags } from './http.js';
|
|
15
15
|
import { formatTaskCreateError } from './task-create-error.js';
|
|
@@ -700,7 +700,6 @@ async function createTask() {
|
|
|
700
700
|
...payload,
|
|
701
701
|
httpStatus: cliError.httpStatus,
|
|
702
702
|
}, null, 2));
|
|
703
|
-
process.exit(1);
|
|
704
703
|
}
|
|
705
704
|
throw cliError;
|
|
706
705
|
}
|
|
@@ -756,8 +755,8 @@ async function taskStatus() {
|
|
|
756
755
|
}
|
|
757
756
|
const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/status`);
|
|
758
757
|
if (!response.ok) {
|
|
758
|
+
const rawBody = await response.text();
|
|
759
759
|
if (hasArg('--json')) {
|
|
760
|
-
const rawBody = await response.text();
|
|
761
760
|
let errorPayload = { error: rawBody };
|
|
762
761
|
try {
|
|
763
762
|
const parsed = JSON.parse(rawBody);
|
|
@@ -772,9 +771,16 @@ async function taskStatus() {
|
|
|
772
771
|
...errorPayload,
|
|
773
772
|
httpStatus: response.status,
|
|
774
773
|
}, null, 2));
|
|
775
|
-
process.exit(1);
|
|
776
774
|
}
|
|
777
|
-
|
|
775
|
+
let errorMsg = rawBody;
|
|
776
|
+
try {
|
|
777
|
+
const parsed = JSON.parse(rawBody);
|
|
778
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.error === 'string') {
|
|
779
|
+
errorMsg = parsed.error;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
catch { /* use rawBody as-is */ }
|
|
783
|
+
throw new Error(`Failed to fetch task status: ${errorMsg}`);
|
|
778
784
|
}
|
|
779
785
|
const data = await parseJsonResponse(response, 'Task status');
|
|
780
786
|
if (hasArg('--json')) {
|
|
@@ -809,12 +815,7 @@ async function appDetail() {
|
|
|
809
815
|
throw new Error('Usage: orizu apps detail --app <appId> [--project <team/project>] [--json]');
|
|
810
816
|
}
|
|
811
817
|
const projectSlug = project || await resolveProjectSlug(null);
|
|
812
|
-
|
|
813
|
-
const app = apps.find(a => a.id === appId);
|
|
814
|
-
if (!app) {
|
|
815
|
-
throw new Error(`App '${appId}' not found in project '${projectSlug}'`);
|
|
816
|
-
}
|
|
817
|
-
// Re-fetch full detail from the list (it already returns full data)
|
|
818
|
+
// Single fetch — the apps endpoint already returns full detail (ALI-544)
|
|
818
819
|
const detailResponse = await authedFetch(`/api/cli/apps?project=${encodeURIComponent(projectSlug)}`);
|
|
819
820
|
if (!detailResponse.ok) {
|
|
820
821
|
throw new Error(`Failed to fetch app detail: ${await detailResponse.text()}`);
|
|
@@ -822,7 +823,7 @@ async function appDetail() {
|
|
|
822
823
|
const detailData = await parseJsonResponse(detailResponse, 'App detail');
|
|
823
824
|
const detail = detailData.apps.find(a => a.id === appId);
|
|
824
825
|
if (!detail) {
|
|
825
|
-
throw new Error(`App '${appId}' not found`);
|
|
826
|
+
throw new Error(`App '${appId}' not found in project '${projectSlug}'`);
|
|
826
827
|
}
|
|
827
828
|
if (hasArg('--json')) {
|
|
828
829
|
console.log(JSON.stringify(detail, null, 2));
|
|
@@ -908,17 +909,7 @@ async function changeTeamMemberRole() {
|
|
|
908
909
|
}
|
|
909
910
|
console.log(`Updated ${member.email} role to ${role}`);
|
|
910
911
|
}
|
|
911
|
-
async function
|
|
912
|
-
const projectArg = getArg('--project');
|
|
913
|
-
const fileArg = getArg('--file');
|
|
914
|
-
const name = getArg('--name');
|
|
915
|
-
if (!fileArg) {
|
|
916
|
-
throw new Error('Usage: orizu datasets upload --file <path> [--project <team/project>] [--name <name>]');
|
|
917
|
-
}
|
|
918
|
-
const file = expandHomePath(fileArg);
|
|
919
|
-
const project = await resolveProjectSlug(projectArg);
|
|
920
|
-
const { rows, sourceType } = parseDatasetFile(file);
|
|
921
|
-
const datasetName = name || basename(file);
|
|
912
|
+
async function createDatasetFromRows(project, name, sourceType, rows) {
|
|
922
913
|
const response = await authedFetch('/api/cli/datasets/upload', {
|
|
923
914
|
method: 'POST',
|
|
924
915
|
headers: {
|
|
@@ -926,7 +917,7 @@ async function uploadDataset() {
|
|
|
926
917
|
},
|
|
927
918
|
body: JSON.stringify({
|
|
928
919
|
projectSlug: project,
|
|
929
|
-
name
|
|
920
|
+
name,
|
|
930
921
|
rows,
|
|
931
922
|
sourceType,
|
|
932
923
|
}),
|
|
@@ -935,7 +926,83 @@ async function uploadDataset() {
|
|
|
935
926
|
const body = await parseJsonResponse(response, 'Dataset upload');
|
|
936
927
|
throw new Error(`Upload failed: ${body.error}`);
|
|
937
928
|
}
|
|
938
|
-
|
|
929
|
+
return parseJsonResponse(response, 'Dataset upload');
|
|
930
|
+
}
|
|
931
|
+
async function uploadJsonlDatasetInChunks(file, project, datasetName) {
|
|
932
|
+
let dataset = null;
|
|
933
|
+
let totalUploaded = 0;
|
|
934
|
+
let chunkIndex = 0;
|
|
935
|
+
const chunks = streamJsonlRowChunks(file)[Symbol.asyncIterator]();
|
|
936
|
+
while (true) {
|
|
937
|
+
let nextChunk;
|
|
938
|
+
try {
|
|
939
|
+
nextChunk = await chunks.next();
|
|
940
|
+
}
|
|
941
|
+
catch (error) {
|
|
942
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
943
|
+
if (!dataset) {
|
|
944
|
+
throw new Error(message);
|
|
945
|
+
}
|
|
946
|
+
throw new Error(`Upload stopped while reading the next JSONL chunk: ${message}\n` +
|
|
947
|
+
`Dataset ${dataset.name} (${dataset.id}) was created and ${totalUploaded} rows were uploaded. ` +
|
|
948
|
+
`Fix the file, remove the first ${totalUploaded} rows, and run ` +
|
|
949
|
+
`orizu datasets append --dataset ${dataset.id} --file <remaining-file>.`);
|
|
950
|
+
}
|
|
951
|
+
if (nextChunk.done) {
|
|
952
|
+
break;
|
|
953
|
+
}
|
|
954
|
+
const chunk = nextChunk.value;
|
|
955
|
+
chunkIndex += 1;
|
|
956
|
+
console.log(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`);
|
|
957
|
+
try {
|
|
958
|
+
if (!dataset) {
|
|
959
|
+
const data = await createDatasetFromRows(project, datasetName, 'jsonl', chunk);
|
|
960
|
+
dataset = data.dataset;
|
|
961
|
+
totalUploaded = data.dataset.rowCount;
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
const data = await appendChunk(dataset.id, chunk);
|
|
965
|
+
totalUploaded += data.appendedCount;
|
|
966
|
+
dataset = {
|
|
967
|
+
...dataset,
|
|
968
|
+
rowCount: data.dataset.rowCount,
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
catch (error) {
|
|
972
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
973
|
+
if (!dataset) {
|
|
974
|
+
throw new Error(message);
|
|
975
|
+
}
|
|
976
|
+
throw new Error(`Chunk ${chunkIndex} failed: ${message}\n` +
|
|
977
|
+
`Dataset ${dataset.name} (${dataset.id}) was created and ${totalUploaded} rows were uploaded. ` +
|
|
978
|
+
`To retry, remove the first ${totalUploaded} rows from your file and run ` +
|
|
979
|
+
`orizu datasets append --dataset ${dataset.id} --file <remaining-file>.`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
if (!dataset) {
|
|
983
|
+
throw new Error('Dataset file contains no rows');
|
|
984
|
+
}
|
|
985
|
+
console.log(`Uploaded dataset ${dataset.name} (${dataset.id}) with ${dataset.rowCount} rows.`);
|
|
986
|
+
if (dataset.url) {
|
|
987
|
+
console.log(`View dataset: ${formatTerminalLink(dataset.url)}`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
async function uploadDataset() {
|
|
991
|
+
const projectArg = getArg('--project');
|
|
992
|
+
const fileArg = getArg('--file');
|
|
993
|
+
const name = getArg('--name');
|
|
994
|
+
if (!fileArg) {
|
|
995
|
+
throw new Error('Usage: orizu datasets upload --file <path> [--project <team/project>] [--name <name>]');
|
|
996
|
+
}
|
|
997
|
+
const file = expandHomePath(fileArg);
|
|
998
|
+
const project = await resolveProjectSlug(projectArg);
|
|
999
|
+
const datasetName = name || basename(file);
|
|
1000
|
+
if (extname(file).toLowerCase() === '.jsonl') {
|
|
1001
|
+
await uploadJsonlDatasetInChunks(file, project, datasetName);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
const { rows, sourceType } = parseDatasetFile(file);
|
|
1005
|
+
const data = await createDatasetFromRows(project, datasetName, sourceType, rows);
|
|
939
1006
|
console.log(`Uploaded dataset ${data.dataset.name} (${data.dataset.id}) with ${data.dataset.rowCount} rows.`);
|
|
940
1007
|
if (data.dataset.url) {
|
|
941
1008
|
console.log(`View dataset: ${formatTerminalLink(data.dataset.url)}`);
|
|
@@ -992,6 +1059,45 @@ async function appendChunk(datasetId, rows) {
|
|
|
992
1059
|
}
|
|
993
1060
|
return parseJsonResponse(response, 'Dataset append');
|
|
994
1061
|
}
|
|
1062
|
+
async function appendJsonlDatasetRowsInChunks(datasetId, file) {
|
|
1063
|
+
let totalAppended = 0;
|
|
1064
|
+
let lastResult = null;
|
|
1065
|
+
let chunkIndex = 0;
|
|
1066
|
+
const chunks = streamJsonlRowChunks(file)[Symbol.asyncIterator]();
|
|
1067
|
+
while (true) {
|
|
1068
|
+
let nextChunk;
|
|
1069
|
+
try {
|
|
1070
|
+
nextChunk = await chunks.next();
|
|
1071
|
+
}
|
|
1072
|
+
catch (error) {
|
|
1073
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1074
|
+
throw new Error(`Append stopped while reading the next JSONL chunk: ${message}\n` +
|
|
1075
|
+
`${totalAppended} rows from ${chunkIndex} chunk(s) were already appended. ` +
|
|
1076
|
+
`Fix the file, remove the first ${totalAppended} rows, and re-run the command.`);
|
|
1077
|
+
}
|
|
1078
|
+
if (nextChunk.done) {
|
|
1079
|
+
break;
|
|
1080
|
+
}
|
|
1081
|
+
const chunk = nextChunk.value;
|
|
1082
|
+
chunkIndex += 1;
|
|
1083
|
+
console.log(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`);
|
|
1084
|
+
try {
|
|
1085
|
+
const data = await appendChunk(datasetId, chunk);
|
|
1086
|
+
totalAppended += data.appendedCount;
|
|
1087
|
+
lastResult = data;
|
|
1088
|
+
}
|
|
1089
|
+
catch (error) {
|
|
1090
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1091
|
+
throw new Error(`Chunk ${chunkIndex} failed: ${message}\n` +
|
|
1092
|
+
`${totalAppended} rows from ${chunkIndex - 1} chunk(s) were already appended. ` +
|
|
1093
|
+
`To retry, remove the first ${totalAppended} rows from your file and re-run the command.`);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
if (!lastResult) {
|
|
1097
|
+
throw new Error('Dataset append file must contain at least one row');
|
|
1098
|
+
}
|
|
1099
|
+
console.log(`Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`);
|
|
1100
|
+
}
|
|
995
1101
|
async function appendDatasetRows() {
|
|
996
1102
|
const projectArg = getArg('--project');
|
|
997
1103
|
const datasetInput = getDatasetReferenceInput();
|
|
@@ -1008,18 +1114,35 @@ async function appendDatasetRows() {
|
|
|
1008
1114
|
datasetId = selected.datasetId;
|
|
1009
1115
|
}
|
|
1010
1116
|
const file = expandHomePath(fileArg);
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1117
|
+
if (extname(file).toLowerCase() === '.jsonl') {
|
|
1118
|
+
await appendJsonlDatasetRowsInChunks(datasetId, file);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
// Check file size before reading to prevent OOM on large files (ALI-565).
|
|
1122
|
+
// Wrap statSync in try/catch so missing/inaccessible files get friendly
|
|
1123
|
+
// errors instead of raw Node.js ENOENT/EPERM (ALI-554).
|
|
1124
|
+
let fileSizeBytes;
|
|
1125
|
+
try {
|
|
1126
|
+
fileSizeBytes = statSync(file).size;
|
|
1127
|
+
}
|
|
1128
|
+
catch (error) {
|
|
1129
|
+
const maybeError = error;
|
|
1130
|
+
if (maybeError.code === 'ENOENT') {
|
|
1131
|
+
throw new Error(`File not found: ${file}. Check the path and filename, then retry.`);
|
|
1132
|
+
}
|
|
1133
|
+
if (maybeError.code === 'EPERM' || maybeError.code === 'EACCES') {
|
|
1134
|
+
throw new Error(`Cannot read file: ${file}. Grant folder permission to your terminal app and retry.`);
|
|
1135
|
+
}
|
|
1136
|
+
throw new Error(`Failed to access file ${file}: ${maybeError.message}`);
|
|
1017
1137
|
}
|
|
1018
|
-
const fileSizeBytes = statSync(file).size;
|
|
1019
1138
|
if (fileSizeBytes > MAX_INPUT_FILE_SIZE_BYTES) {
|
|
1020
1139
|
const sizeMb = (fileSizeBytes / (1024 * 1024)).toFixed(1);
|
|
1021
1140
|
throw new Error(`Input file is ${sizeMb} MB, which exceeds the 50 MB limit. Split the file into smaller parts and append each separately.`);
|
|
1022
1141
|
}
|
|
1142
|
+
const { rows } = parseDatasetFile(file);
|
|
1143
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
1144
|
+
throw new Error('Dataset append file must contain at least one row');
|
|
1145
|
+
}
|
|
1023
1146
|
if (rows.length <= APPEND_CHUNK_SIZE_ROWS) {
|
|
1024
1147
|
const data = await appendChunk(datasetId, rows);
|
|
1025
1148
|
console.log(`Appended ${data.appendedCount} rows to dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createReadStream } from 'fs';
|
|
2
|
+
import { createInterface } from 'readline';
|
|
3
|
+
export const JSONL_UPLOAD_CHUNK_MAX_BYTES = 2 * 1024 * 1024;
|
|
4
|
+
function isObjectRow(value) {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function rowsPayloadByteLength(rowCount, rowsByteLength) {
|
|
8
|
+
return Buffer.byteLength('{"rows":[]}', 'utf8') +
|
|
9
|
+
rowsByteLength +
|
|
10
|
+
Math.max(0, rowCount - 1);
|
|
11
|
+
}
|
|
12
|
+
function formatMb(byteLength) {
|
|
13
|
+
return (byteLength / (1024 * 1024)).toFixed(1);
|
|
14
|
+
}
|
|
15
|
+
function mapReadError(filePath, error) {
|
|
16
|
+
const maybeError = error;
|
|
17
|
+
if (!maybeError.code && error instanceof Error) {
|
|
18
|
+
return error;
|
|
19
|
+
}
|
|
20
|
+
if (maybeError.code === 'ENOENT') {
|
|
21
|
+
return new Error(`File not found: ${filePath}. Check the path and filename, then retry.`);
|
|
22
|
+
}
|
|
23
|
+
if (maybeError.code === 'EPERM' || maybeError.code === 'EACCES') {
|
|
24
|
+
return new Error(`Cannot read file: ${filePath}. macOS may be blocking access to this folder (for example Downloads). Grant folder permission to your terminal app and retry.`);
|
|
25
|
+
}
|
|
26
|
+
return new Error(`Failed to read file ${filePath}: ${maybeError.message}`);
|
|
27
|
+
}
|
|
28
|
+
export function estimateRowsPayloadByteLength(rows) {
|
|
29
|
+
const rowsByteLength = rows.reduce((total, row) => {
|
|
30
|
+
return total + Buffer.byteLength(JSON.stringify(row), 'utf8');
|
|
31
|
+
}, 0);
|
|
32
|
+
return rowsPayloadByteLength(rows.length, rowsByteLength);
|
|
33
|
+
}
|
|
34
|
+
export async function* streamJsonlRowChunks(filePath, options = {}) {
|
|
35
|
+
const maxPayloadBytes = options.maxPayloadBytes ?? JSONL_UPLOAD_CHUNK_MAX_BYTES;
|
|
36
|
+
let chunk = [];
|
|
37
|
+
let chunkRowsByteLength = 0;
|
|
38
|
+
let parsedRowCount = 0;
|
|
39
|
+
let lineNumber = 0;
|
|
40
|
+
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
|
41
|
+
const lines = createInterface({
|
|
42
|
+
input: stream,
|
|
43
|
+
crlfDelay: Infinity,
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
for await (const line of lines) {
|
|
47
|
+
lineNumber += 1;
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (!trimmed) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(trimmed);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error(`Invalid JSONL at line ${lineNumber}`);
|
|
58
|
+
}
|
|
59
|
+
if (!isObjectRow(parsed)) {
|
|
60
|
+
throw new Error(`Row ${lineNumber} must be a JSON object`);
|
|
61
|
+
}
|
|
62
|
+
const jsonByteLength = Buffer.byteLength(JSON.stringify(parsed), 'utf8');
|
|
63
|
+
const singleRowPayloadBytes = rowsPayloadByteLength(1, jsonByteLength);
|
|
64
|
+
if (singleRowPayloadBytes > maxPayloadBytes) {
|
|
65
|
+
throw new Error(`Row ${lineNumber} is ${formatMb(singleRowPayloadBytes)} MB after JSON encoding, which exceeds the ${formatMb(maxPayloadBytes)} MB upload chunk limit`);
|
|
66
|
+
}
|
|
67
|
+
const nextPayloadBytes = rowsPayloadByteLength(chunk.length + 1, chunkRowsByteLength + jsonByteLength);
|
|
68
|
+
if (chunk.length > 0 && nextPayloadBytes > maxPayloadBytes) {
|
|
69
|
+
yield chunk.map(item => item.row);
|
|
70
|
+
chunk = [];
|
|
71
|
+
chunkRowsByteLength = 0;
|
|
72
|
+
}
|
|
73
|
+
chunk.push({ row: parsed, jsonByteLength });
|
|
74
|
+
chunkRowsByteLength += jsonByteLength;
|
|
75
|
+
parsedRowCount += 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
throw mapReadError(filePath, error);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
lines.close();
|
|
83
|
+
stream.destroy();
|
|
84
|
+
}
|
|
85
|
+
if (chunk.length > 0) {
|
|
86
|
+
yield chunk.map(item => item.row);
|
|
87
|
+
}
|
|
88
|
+
if (parsedRowCount === 0) {
|
|
89
|
+
throw new Error('Dataset file contains no rows');
|
|
90
|
+
}
|
|
91
|
+
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createHash, randomBytes } from 'crypto'
|
|
3
|
-
import { basename } from 'path'
|
|
3
|
+
import { basename, extname } from 'path'
|
|
4
4
|
import { createServer } from 'http'
|
|
5
5
|
import { readFileSync, statSync, writeFileSync } from 'fs'
|
|
6
6
|
import { spawn } from 'child_process'
|
|
@@ -8,8 +8,8 @@ import { createInterface } from 'readline/promises'
|
|
|
8
8
|
import { stdin as input, stdout as output } from 'process'
|
|
9
9
|
import { clearServerCredentials, getServerCredentials, saveServerCredentials } from './credentials.js'
|
|
10
10
|
import { parseDatasetFile } from './file-parser.js'
|
|
11
|
+
import { streamJsonlRowChunks } from './jsonl-stream.js'
|
|
11
12
|
import { parseDatasetReference } from './dataset-download.js'
|
|
12
|
-
import { extractErrorMessage } from './error-response.js'
|
|
13
13
|
import { parseGlobalFlags } from './global-flags.js'
|
|
14
14
|
import { authedFetch, getBaseUrl, resolveLoginBaseUrl, setGlobalFlags } from './http.js'
|
|
15
15
|
import { formatTaskCreateError } from './task-create-error.js'
|
|
@@ -67,6 +67,18 @@ interface DatasetSummary {
|
|
|
67
67
|
teamSlug: string
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
type DatasetUploadSourceType = 'csv' | 'json' | 'jsonl'
|
|
71
|
+
|
|
72
|
+
interface DatasetUploadResponse {
|
|
73
|
+
dataset: {
|
|
74
|
+
id: string
|
|
75
|
+
name: string
|
|
76
|
+
rowCount: number
|
|
77
|
+
sourceType: string
|
|
78
|
+
url?: string
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
70
82
|
interface TeamMember {
|
|
71
83
|
id: string
|
|
72
84
|
user_id: string | null
|
|
@@ -1340,21 +1352,12 @@ async function changeTeamMemberRole() {
|
|
|
1340
1352
|
console.log(`Updated ${member.email} role to ${role}`)
|
|
1341
1353
|
}
|
|
1342
1354
|
|
|
1343
|
-
async function
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
throw new Error('Usage: orizu datasets upload --file <path> [--project <team/project>] [--name <name>]')
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
const file = expandHomePath(fileArg)
|
|
1353
|
-
const project = await resolveProjectSlug(projectArg)
|
|
1354
|
-
|
|
1355
|
-
const { rows, sourceType } = parseDatasetFile(file)
|
|
1356
|
-
const datasetName = name || basename(file)
|
|
1357
|
-
|
|
1355
|
+
async function createDatasetFromRows(
|
|
1356
|
+
project: string,
|
|
1357
|
+
name: string,
|
|
1358
|
+
sourceType: DatasetUploadSourceType,
|
|
1359
|
+
rows: Array<Record<string, unknown>>
|
|
1360
|
+
): Promise<DatasetUploadResponse> {
|
|
1358
1361
|
const response = await authedFetch('/api/cli/datasets/upload', {
|
|
1359
1362
|
method: 'POST',
|
|
1360
1363
|
headers: {
|
|
@@ -1362,7 +1365,7 @@ async function uploadDataset() {
|
|
|
1362
1365
|
},
|
|
1363
1366
|
body: JSON.stringify({
|
|
1364
1367
|
projectSlug: project,
|
|
1365
|
-
name
|
|
1368
|
+
name,
|
|
1366
1369
|
rows,
|
|
1367
1370
|
sourceType,
|
|
1368
1371
|
}),
|
|
@@ -1373,9 +1376,104 @@ async function uploadDataset() {
|
|
|
1373
1376
|
throw new Error(`Upload failed: ${body.error}`)
|
|
1374
1377
|
}
|
|
1375
1378
|
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
+
return parseJsonResponse<DatasetUploadResponse>(response, 'Dataset upload')
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
async function uploadJsonlDatasetInChunks(
|
|
1383
|
+
file: string,
|
|
1384
|
+
project: string,
|
|
1385
|
+
datasetName: string
|
|
1386
|
+
) {
|
|
1387
|
+
let dataset: DatasetUploadResponse['dataset'] | null = null
|
|
1388
|
+
let totalUploaded = 0
|
|
1389
|
+
let chunkIndex = 0
|
|
1390
|
+
const chunks = streamJsonlRowChunks(file)[Symbol.asyncIterator]()
|
|
1391
|
+
|
|
1392
|
+
while (true) {
|
|
1393
|
+
let nextChunk: IteratorResult<Array<Record<string, unknown>>>
|
|
1394
|
+
try {
|
|
1395
|
+
nextChunk = await chunks.next()
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1398
|
+
if (!dataset) {
|
|
1399
|
+
throw new Error(message)
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
throw new Error(
|
|
1403
|
+
`Upload stopped while reading the next JSONL chunk: ${message}\n` +
|
|
1404
|
+
`Dataset ${dataset.name} (${dataset.id}) was created and ${totalUploaded} rows were uploaded. ` +
|
|
1405
|
+
`Fix the file, remove the first ${totalUploaded} rows, and run ` +
|
|
1406
|
+
`orizu datasets append --dataset ${dataset.id} --file <remaining-file>.`
|
|
1407
|
+
)
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
if (nextChunk.done) {
|
|
1411
|
+
break
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const chunk = nextChunk.value
|
|
1415
|
+
chunkIndex += 1
|
|
1416
|
+
console.log(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`)
|
|
1417
|
+
|
|
1418
|
+
try {
|
|
1419
|
+
if (!dataset) {
|
|
1420
|
+
const data = await createDatasetFromRows(project, datasetName, 'jsonl', chunk)
|
|
1421
|
+
dataset = data.dataset
|
|
1422
|
+
totalUploaded = data.dataset.rowCount
|
|
1423
|
+
continue
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const data = await appendChunk(dataset.id, chunk)
|
|
1427
|
+
totalUploaded += data.appendedCount
|
|
1428
|
+
dataset = {
|
|
1429
|
+
...dataset,
|
|
1430
|
+
rowCount: data.dataset.rowCount,
|
|
1431
|
+
}
|
|
1432
|
+
} catch (error) {
|
|
1433
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1434
|
+
if (!dataset) {
|
|
1435
|
+
throw new Error(message)
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
throw new Error(
|
|
1439
|
+
`Chunk ${chunkIndex} failed: ${message}\n` +
|
|
1440
|
+
`Dataset ${dataset.name} (${dataset.id}) was created and ${totalUploaded} rows were uploaded. ` +
|
|
1441
|
+
`To retry, remove the first ${totalUploaded} rows from your file and run ` +
|
|
1442
|
+
`orizu datasets append --dataset ${dataset.id} --file <remaining-file>.`
|
|
1443
|
+
)
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
if (!dataset) {
|
|
1448
|
+
throw new Error('Dataset file contains no rows')
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
console.log(`Uploaded dataset ${dataset.name} (${dataset.id}) with ${dataset.rowCount} rows.`)
|
|
1452
|
+
if (dataset.url) {
|
|
1453
|
+
console.log(`View dataset: ${formatTerminalLink(dataset.url)}`)
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
async function uploadDataset() {
|
|
1458
|
+
const projectArg = getArg('--project')
|
|
1459
|
+
const fileArg = getArg('--file')
|
|
1460
|
+
const name = getArg('--name')
|
|
1461
|
+
|
|
1462
|
+
if (!fileArg) {
|
|
1463
|
+
throw new Error('Usage: orizu datasets upload --file <path> [--project <team/project>] [--name <name>]')
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const file = expandHomePath(fileArg)
|
|
1467
|
+
const project = await resolveProjectSlug(projectArg)
|
|
1468
|
+
const datasetName = name || basename(file)
|
|
1469
|
+
|
|
1470
|
+
if (extname(file).toLowerCase() === '.jsonl') {
|
|
1471
|
+
await uploadJsonlDatasetInChunks(file, project, datasetName)
|
|
1472
|
+
return
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
const { rows, sourceType } = parseDatasetFile(file)
|
|
1476
|
+
const data = await createDatasetFromRows(project, datasetName, sourceType, rows)
|
|
1379
1477
|
|
|
1380
1478
|
console.log(`Uploaded dataset ${data.dataset.name} (${data.dataset.id}) with ${data.dataset.rowCount} rows.`)
|
|
1381
1479
|
if (data.dataset.url) {
|
|
@@ -1455,6 +1553,56 @@ async function appendChunk(
|
|
|
1455
1553
|
}>(response, 'Dataset append')
|
|
1456
1554
|
}
|
|
1457
1555
|
|
|
1556
|
+
async function appendJsonlDatasetRowsInChunks(datasetId: string, file: string) {
|
|
1557
|
+
let totalAppended = 0
|
|
1558
|
+
let lastResult: { dataset: { id: string; name: string; rowCount: number } } | null = null
|
|
1559
|
+
let chunkIndex = 0
|
|
1560
|
+
const chunks = streamJsonlRowChunks(file)[Symbol.asyncIterator]()
|
|
1561
|
+
|
|
1562
|
+
while (true) {
|
|
1563
|
+
let nextChunk: IteratorResult<Array<Record<string, unknown>>>
|
|
1564
|
+
try {
|
|
1565
|
+
nextChunk = await chunks.next()
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1568
|
+
throw new Error(
|
|
1569
|
+
`Append stopped while reading the next JSONL chunk: ${message}\n` +
|
|
1570
|
+
`${totalAppended} rows from ${chunkIndex} chunk(s) were already appended. ` +
|
|
1571
|
+
`Fix the file, remove the first ${totalAppended} rows, and re-run the command.`
|
|
1572
|
+
)
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
if (nextChunk.done) {
|
|
1576
|
+
break
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
const chunk = nextChunk.value
|
|
1580
|
+
chunkIndex += 1
|
|
1581
|
+
console.log(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`)
|
|
1582
|
+
|
|
1583
|
+
try {
|
|
1584
|
+
const data = await appendChunk(datasetId, chunk)
|
|
1585
|
+
totalAppended += data.appendedCount
|
|
1586
|
+
lastResult = data
|
|
1587
|
+
} catch (error) {
|
|
1588
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1589
|
+
throw new Error(
|
|
1590
|
+
`Chunk ${chunkIndex} failed: ${message}\n` +
|
|
1591
|
+
`${totalAppended} rows from ${chunkIndex - 1} chunk(s) were already appended. ` +
|
|
1592
|
+
`To retry, remove the first ${totalAppended} rows from your file and re-run the command.`
|
|
1593
|
+
)
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
if (!lastResult) {
|
|
1598
|
+
throw new Error('Dataset append file must contain at least one row')
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
console.log(
|
|
1602
|
+
`Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`
|
|
1603
|
+
)
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1458
1606
|
async function appendDatasetRows() {
|
|
1459
1607
|
const projectArg = getArg('--project')
|
|
1460
1608
|
const datasetInput = getDatasetReferenceInput()
|
|
@@ -1474,6 +1622,11 @@ async function appendDatasetRows() {
|
|
|
1474
1622
|
|
|
1475
1623
|
const file = expandHomePath(fileArg)
|
|
1476
1624
|
|
|
1625
|
+
if (extname(file).toLowerCase() === '.jsonl') {
|
|
1626
|
+
await appendJsonlDatasetRowsInChunks(datasetId, file)
|
|
1627
|
+
return
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1477
1630
|
// Check file size before reading to prevent OOM on large files (ALI-565).
|
|
1478
1631
|
// Wrap statSync in try/catch so missing/inaccessible files get friendly
|
|
1479
1632
|
// errors instead of raw Node.js ENOENT/EPERM (ALI-554).
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createReadStream } from 'fs'
|
|
2
|
+
import { createInterface } from 'readline'
|
|
3
|
+
|
|
4
|
+
export const JSONL_UPLOAD_CHUNK_MAX_BYTES = 2 * 1024 * 1024
|
|
5
|
+
|
|
6
|
+
interface JsonlChunkOptions {
|
|
7
|
+
maxPayloadBytes?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface PendingRow {
|
|
11
|
+
row: Record<string, unknown>
|
|
12
|
+
jsonByteLength: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isObjectRow(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function rowsPayloadByteLength(rowCount: number, rowsByteLength: number): number {
|
|
20
|
+
return Buffer.byteLength('{"rows":[]}', 'utf8') +
|
|
21
|
+
rowsByteLength +
|
|
22
|
+
Math.max(0, rowCount - 1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatMb(byteLength: number): string {
|
|
26
|
+
return (byteLength / (1024 * 1024)).toFixed(1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mapReadError(filePath: string, error: unknown): Error {
|
|
30
|
+
const maybeError = error as NodeJS.ErrnoException
|
|
31
|
+
|
|
32
|
+
if (!maybeError.code && error instanceof Error) {
|
|
33
|
+
return error
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (maybeError.code === 'ENOENT') {
|
|
37
|
+
return new Error(
|
|
38
|
+
`File not found: ${filePath}. Check the path and filename, then retry.`
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (maybeError.code === 'EPERM' || maybeError.code === 'EACCES') {
|
|
43
|
+
return new Error(
|
|
44
|
+
`Cannot read file: ${filePath}. macOS may be blocking access to this folder (for example Downloads). Grant folder permission to your terminal app and retry.`
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return new Error(`Failed to read file ${filePath}: ${maybeError.message}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function estimateRowsPayloadByteLength(rows: Array<Record<string, unknown>>): number {
|
|
52
|
+
const rowsByteLength = rows.reduce((total, row) => {
|
|
53
|
+
return total + Buffer.byteLength(JSON.stringify(row), 'utf8')
|
|
54
|
+
}, 0)
|
|
55
|
+
|
|
56
|
+
return rowsPayloadByteLength(rows.length, rowsByteLength)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function* streamJsonlRowChunks(
|
|
60
|
+
filePath: string,
|
|
61
|
+
options: JsonlChunkOptions = {}
|
|
62
|
+
): AsyncGenerator<Array<Record<string, unknown>>> {
|
|
63
|
+
const maxPayloadBytes = options.maxPayloadBytes ?? JSONL_UPLOAD_CHUNK_MAX_BYTES
|
|
64
|
+
let chunk: PendingRow[] = []
|
|
65
|
+
let chunkRowsByteLength = 0
|
|
66
|
+
let parsedRowCount = 0
|
|
67
|
+
let lineNumber = 0
|
|
68
|
+
|
|
69
|
+
const stream = createReadStream(filePath, { encoding: 'utf8' })
|
|
70
|
+
const lines = createInterface({
|
|
71
|
+
input: stream,
|
|
72
|
+
crlfDelay: Infinity,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
for await (const line of lines) {
|
|
77
|
+
lineNumber += 1
|
|
78
|
+
const trimmed = line.trim()
|
|
79
|
+
if (!trimmed) {
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let parsed: unknown
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(trimmed)
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error(`Invalid JSONL at line ${lineNumber}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!isObjectRow(parsed)) {
|
|
91
|
+
throw new Error(`Row ${lineNumber} must be a JSON object`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const jsonByteLength = Buffer.byteLength(JSON.stringify(parsed), 'utf8')
|
|
95
|
+
const singleRowPayloadBytes = rowsPayloadByteLength(1, jsonByteLength)
|
|
96
|
+
if (singleRowPayloadBytes > maxPayloadBytes) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Row ${lineNumber} is ${formatMb(singleRowPayloadBytes)} MB after JSON encoding, which exceeds the ${formatMb(maxPayloadBytes)} MB upload chunk limit`
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const nextPayloadBytes = rowsPayloadByteLength(
|
|
103
|
+
chunk.length + 1,
|
|
104
|
+
chunkRowsByteLength + jsonByteLength
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if (chunk.length > 0 && nextPayloadBytes > maxPayloadBytes) {
|
|
108
|
+
yield chunk.map(item => item.row)
|
|
109
|
+
chunk = []
|
|
110
|
+
chunkRowsByteLength = 0
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
chunk.push({ row: parsed, jsonByteLength })
|
|
114
|
+
chunkRowsByteLength += jsonByteLength
|
|
115
|
+
parsedRowCount += 1
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw mapReadError(filePath, error)
|
|
119
|
+
} finally {
|
|
120
|
+
lines.close()
|
|
121
|
+
stream.destroy()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (chunk.length > 0) {
|
|
125
|
+
yield chunk.map(item => item.row)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (parsedRowCount === 0) {
|
|
129
|
+
throw new Error('Dataset file contains no rows')
|
|
130
|
+
}
|
|
131
|
+
}
|