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 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
- throw new Error(`Failed to fetch task status: ${await extractErrorMessage(response)}`);
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
- const apps = await fetchApps(projectSlug);
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 uploadDataset() {
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: datasetName,
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
- const data = await parseJsonResponse(response, 'Dataset upload');
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
- // Parse the file first — parseDatasetFile provides user-friendly errors for
1012
- // ENOENT, EPERM, and EACCES. We check the file size afterward using the
1013
- // already-read content length to avoid a raw statSync ENOENT (ALI-554).
1014
- const { rows } = parseDatasetFile(file);
1015
- if (!Array.isArray(rows) || rows.length === 0) {
1016
- throw new Error('Dataset append file must contain at least one row');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
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 uploadDataset() {
1344
- const projectArg = getArg('--project')
1345
- const fileArg = getArg('--file')
1346
- const name = getArg('--name')
1347
-
1348
- if (!fileArg) {
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: datasetName,
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
- const data = await parseJsonResponse<{
1377
- dataset: { id: string; name: string; rowCount: number; sourceType: string; url?: string }
1378
- }>(response, 'Dataset upload')
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
+ }