diff --git a/README.md b/README.md index 21f5191a..3b18500b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,22 @@ This server provides a management interface for job trees and includes a RESTful * Manages job trees. * Provides a RESTful API for database management. +* Paginated list endpoints for jobs, stages, and tasks. + +## Pagination + +The `GET /v1/jobs`, `GET /v1/stages`, and `GET /v1/tasks` list endpoints support `page` (1-based, default `1`) and `page_size` (1–100, default `10`) query parameters. + +Responses are wrapped in `{ total, items }` instead of a plain array — `total` is the full match count regardless of page. + +``` +GET /v1/jobs?page=2&page_size=5 +→ { "total": 15, "items": [...5 jobs...] } +``` + +Requesting beyond the last page returns `items: []`, not an error. + +`GET /v1/jobs/:jobId/stages` is scoped to a single job and returns a plain array — a job's stages are bounded in number, so pagination isn't needed there. **Requirements:** diff --git a/eslint.config.mjs b/eslint.config.mjs index dcedab41..9aec36d7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,8 +11,18 @@ const AllowedSqlOperators = { }, }; +const AllowedSnakeCaseDestructured = { + selector: 'variable', + modifiers: ['destructured'], + format: null, + filter: { + match: true, + regex: '^page_size$', + }, +}; + // Create a new array with the base rules and our custom rule -const namingConvention = [...namingConventions, AllowedSqlOperators]; +const namingConvention = [...namingConventions, AllowedSqlOperators, AllowedSnakeCaseDestructured]; const customConfig = { rules: { diff --git a/openapi3.yaml b/openapi3.yaml index d2eedc94..a50d0e7d 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -2,10 +2,10 @@ openapi: 3.0.3 info: title: Job Manager Service - API v1 description: Job Manager Service API version 1 - version: 0.2.1 + version: 0.2.0 license: name: MIT - url: https://opensource.org/licenses/MIT + url: 'https://opensource.org/licenses/MIT' security: [] paths: /v1/jobs: @@ -17,6 +17,8 @@ paths: - $ref: '#/components/parameters/endDate' - $ref: '#/components/parameters/priority' - $ref: '#/components/parameters/includeStages' + - $ref: '#/components/parameters/pageParam' + - $ref: '#/components/parameters/pageSizeParam' summary: Retrieve jobs matching criteria description: | Filter jobs by name, date range, priority. Optional stage inclusion. @@ -29,9 +31,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/job' + $ref: '#/components/schemas/jobsPaginatedResponse' '400': description: Invalid query parameters content: @@ -109,7 +109,7 @@ paths: input_path: /data/traced/batch_001 output_path: /data/output/batch_001 traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 - tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE + tracestate: 'rojo=00f067aa0ba902b7,congo=t61rcWkgMzE' responses: '201': description: Job created successfully @@ -172,9 +172,9 @@ paths: deadline: '2025-08-01T00:00:00.000Z' cost_center: CC-12345 traceparent: 00-660f9511f3ac52e5b827557766551111-22b378cc5ca902b7-01 - tracestate: analytics=dept123,priority=very_high + tracestate: 'analytics=dept123,priority=very_high' '400': - description: Invalid request, could not create job + description: 'Invalid request, could not create job' content: application/json: schema: @@ -209,7 +209,7 @@ paths: application/json: schema: $ref: '#/components/schemas/internalErrorsResponse' - /v1/jobs/{jobId}: + '/v1/jobs/{jobId}': parameters: - $ref: '#/components/parameters/jobId' get: @@ -348,14 +348,14 @@ paths: traceparent: 00-660f9511f3ac52e5b827557766551111-22b378cc5ca902b7-01 data: source_path: /data/production - destination: s3://backup-bucket/2025-07-26 + destination: 's3://backup-bucket/2025-07-26' compression: gzip encryption: true userMetadata: backup_type: daily retention_days: 30 '400': - description: Invalid request, could not get job + description: 'Invalid request, could not get job' content: application/json: schema: @@ -374,7 +374,7 @@ paths: $ref: '#/components/schemas/internalErrorsResponse' delete: operationId: deleteJobV1 - summary: Delete a job and all its associated resources (stages, tasks) + summary: 'Delete a job and all its associated resources (stages, tasks)' description: > Permanently removes a job and all its associated stages and tasks from the system. @@ -438,7 +438,7 @@ paths: application/json: schema: $ref: '#/components/schemas/internalErrorsResponse' - /v1/jobs/{jobId}/user-metadata: + '/v1/jobs/{jobId}/user-metadata': patch: operationId: updateUserMetadataV1 parameters: @@ -492,7 +492,7 @@ paths: application/json: schema: $ref: '#/components/schemas/internalErrorsResponse' - /v1/jobs/{jobId}/priority: + '/v1/jobs/{jobId}/priority': parameters: - $ref: '#/components/parameters/jobId' patch: @@ -560,7 +560,7 @@ paths: application/json: schema: $ref: '#/components/schemas/internalErrorsResponse' - /v1/jobs/{jobId}/status: + '/v1/jobs/{jobId}/status': parameters: - $ref: '#/components/parameters/jobId' put: @@ -645,7 +645,7 @@ paths: application/json: schema: $ref: '#/components/schemas/internalErrorsResponse' - /v1/jobs/{jobId}/stages: + '/v1/jobs/{jobId}/stages': parameters: - $ref: '#/components/parameters/jobId' get: @@ -704,7 +704,7 @@ paths: application/json: schema: $ref: '#/components/schemas/internalErrorsResponse' - /v1/jobs/{jobId}/stage: + '/v1/jobs/{jobId}/stage': post: operationId: addStageV1 summary: Add a new stage as the last stage in the job workflow @@ -781,7 +781,7 @@ paths: validation_type: schema_check strict_mode: true traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 - tracestate: vendor=trace123,service=validation + tracestate: 'vendor=trace123,service=validation' responses: '201': description: Stage successfully created and added to the job @@ -819,7 +819,7 @@ paths: retried: 0 total: 0 traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-33c489dd6db902b7-01 - tracestate: processing=batch001,stage=data_proc + tracestate: 'processing=batch001,stage=data_proc' waiting_stage_response: summary: Response for manual approval stage creation value: @@ -888,6 +888,8 @@ paths: - $ref: '#/components/parameters/paramStageType' - $ref: '#/components/parameters/stageStatus' - $ref: '#/components/parameters/includeTasks' + - $ref: '#/components/parameters/pageParam' + - $ref: '#/components/parameters/pageSizeParam' summary: Retrieve stages matching specified criteria description: > Returns a filtered list of stages based on the provided query @@ -909,9 +911,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/getStageResponse' + $ref: '#/components/schemas/stagesPaginatedResponse' '400': description: Invalid query parameters content: @@ -924,7 +924,7 @@ paths: application/json: schema: $ref: '#/components/schemas/internalErrorsResponse' - /v1/stages/{stageId}: + '/v1/stages/{stageId}': parameters: - $ref: '#/components/parameters/stageId' get: @@ -971,7 +971,7 @@ paths: application/json: schema: $ref: '#/components/schemas/internalErrorsResponse' - /v1/stages/{stageId}/summary: + '/v1/stages/{stageId}/summary': get: operationId: getStageSummaryV1 parameters: @@ -1017,7 +1017,7 @@ paths: application/json: schema: $ref: '#/components/schemas/internalErrorsResponse' - /v1/stages/{stageId}/user-metadata: + '/v1/stages/{stageId}/user-metadata': patch: operationId: updateStageUserMetadataV1 parameters: @@ -1071,7 +1071,7 @@ paths: application/json: schema: $ref: '#/components/schemas/internalErrorsResponse' - /v1/stages/{stageId}/status: + '/v1/stages/{stageId}/status': put: operationId: updateStageStatusV1 parameters: @@ -1179,7 +1179,7 @@ paths: - UNKNOWN_ERROR - ILLEGAL_JOB_STATUS_TRANSITION - JOB_NOT_FOUND - /v1/stages/{stageId}/tasks: + '/v1/stages/{stageId}/tasks': parameters: - $ref: '#/components/parameters/stageId' get: @@ -1190,15 +1190,16 @@ paths: Provides complete information about each task including type, status, and attempt count. + parameters: + - $ref: '#/components/parameters/pageParam' + - $ref: '#/components/parameters/pageSizeParam' responses: '200': description: Successfully retrieved tasks for the specified stage content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/taskResponse' + $ref: '#/components/schemas/tasksPaginatedResponse' '400': description: Invalid stage ID format or other parameter error content: @@ -1282,10 +1283,10 @@ paths: summary: API integration tasks with different configurations value: - data: - endpoint: https://api.external.com/customers + endpoint: 'https://api.external.com/customers' method: GET headers: - Authorization: Bearer ${API_TOKEN} + Authorization: 'Bearer ${API_TOKEN}' pagination: page_size: 100 max_pages: 50 @@ -1293,10 +1294,10 @@ paths: source: customer_system priority: high - data: - endpoint: https://api.external.com/orders + endpoint: 'https://api.external.com/orders' method: GET headers: - Authorization: Bearer ${API_TOKEN} + Authorization: 'Bearer ${API_TOKEN}' query_params: start_date: '2025-01-01T00:00:00.000Z' end_date: '2025-07-27T00:00:00.000Z' @@ -1314,7 +1315,7 @@ paths: - demographic_api - credit_api traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 - tracestate: customer=enrich123,priority=high + tracestate: 'customer=enrich123,priority=high' maxAttempts: 2 - data: process_type: data_validation @@ -1351,7 +1352,7 @@ paths: creationTime: '2025-07-27T10:30:00.000Z' updateTime: '2025-07-27T10:30:00.000Z' traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-55e6abbf8fd902b7-01 - tracestate: file=001,processing=csv_parquet + tracestate: 'file=001,processing=csv_parquet' - id: 469e8a0c-9232-4144-920f-fb5cef356ff7 data: file_path: /data/input/file_002.csv @@ -1367,7 +1368,7 @@ paths: creationTime: '2025-07-27T10:30:00.000Z' updateTime: '2025-07-27T10:30:00.000Z' traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-66f7bcc090e902b7-01 - tracestate: file=002,processing=csv_parquet + tracestate: 'file=002,processing=csv_parquet' - id: 6a7ecf04-4455-41d1-aa04-8786178cb4a3 data: file_path: /data/input/file_003.csv @@ -1383,16 +1384,16 @@ paths: creationTime: '2025-07-27T10:30:00.000Z' updateTime: '2025-07-27T10:30:00.000Z' traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-77081dd1a1f902b7-01 - tracestate: file=003,processing=csv_parquet + tracestate: 'file=003,processing=csv_parquet' api_integration_tasks_response: summary: Response for API integration tasks creation value: - id: ddfad658-da33-4573-b5a3-d70a204a3e0b data: - endpoint: https://api.external.com/customers + endpoint: 'https://api.external.com/customers' method: GET headers: - Authorization: Bearer ${API_TOKEN} + Authorization: 'Bearer ${API_TOKEN}' pagination: page_size: 100 max_pages: 50 @@ -1406,13 +1407,13 @@ paths: creationTime: '2025-07-27T10:30:00.000Z' updateTime: '2025-07-27T10:30:00.000Z' traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-88192ee2b20902b7-01 - tracestate: api=customers,source=external + tracestate: 'api=customers,source=external' - id: df59d74e-f442-4e66-b8c9-b312ad948c47 data: - endpoint: https://api.external.com/orders + endpoint: 'https://api.external.com/orders' method: GET headers: - Authorization: Bearer ${API_TOKEN} + Authorization: 'Bearer ${API_TOKEN}' query_params: start_date: '2025-01-01T00:00:00.000Z' end_date: '2025-07-27T00:00:00.000Z' @@ -1426,7 +1427,7 @@ paths: creationTime: '2025-07-27T10:30:00.000Z' updateTime: '2025-07-27T10:30:00.000Z' traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-99203ff3c31902b7-01 - tracestate: api=orders,source=external + tracestate: 'api=orders,source=external' tasks_with_lifecycle_states: summary: >- Tasks showing different lifecycle states with timing @@ -1526,7 +1527,7 @@ paths: application/json: schema: $ref: '#/components/schemas/internalErrorsResponse' - /v1/stages/{stageType}/tasks/dequeue: + '/v1/stages/{stageType}/tasks/dequeue': parameters: - $ref: '#/components/parameters/stageType' patch: @@ -1619,6 +1620,8 @@ paths: - $ref: '#/components/parameters/fromDate' - $ref: '#/components/parameters/endDate' - $ref: '#/components/parameters/paramsTaskStatus' + - $ref: '#/components/parameters/pageParam' + - $ref: '#/components/parameters/pageSizeParam' get: operationId: getTasksByCriteriaV1 summary: Retrieve tasks matching specified criteria @@ -1640,9 +1643,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/taskResponse' + $ref: '#/components/schemas/tasksPaginatedResponse' '400': description: Invalid query parameters content: @@ -1657,7 +1658,7 @@ paths: $ref: '#/components/schemas/internalErrorsResponse' tags: - tasks - /v1/tasks/{taskId}: + '/v1/tasks/{taskId}': parameters: - $ref: '#/components/parameters/taskId' get: @@ -1695,7 +1696,7 @@ paths: $ref: '#/components/schemas/internalErrorsResponse' tags: - tasks - /v1/tasks/{taskId}/user-metadata: + '/v1/tasks/{taskId}/user-metadata': patch: operationId: updateTaskUserMetadataV1 parameters: @@ -1749,7 +1750,7 @@ paths: application/json: schema: $ref: '#/components/schemas/internalErrorsResponse' - /v1/tasks/{taskId}/status: + '/v1/tasks/{taskId}/status': parameters: - $ref: '#/components/parameters/taskId' put: @@ -1909,7 +1910,7 @@ components: type: integer minimum: 0 maximum: 100 - description: Completion percentage of a job, stage, or task (0-100) + description: 'Completion percentage of a job, stage, or task (0-100)' attempts: type: integer minimum: 0 @@ -1948,7 +1949,7 @@ components: description: | W3C traceparent for distributed tracing. Auto-injected if not provided. See [W3C Trace Context](https://www.w3.org/TR/trace-context/). - pattern: ^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$ + pattern: '^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$' example: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 tracestate: type: string @@ -1957,7 +1958,7 @@ components: available. pattern: >- ^[a-z0-9][a-z0-9_\\-\\*\\/]*=[^,=]+(?:,[a-z0-9][a-z0-9_\\-\\*\\/]*=[^,=]+)*$ - example: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE + example: 'rojo=00f067aa0ba902b7,congo=t61rcWkgMzE' successMessages: type: string enum: @@ -2345,7 +2346,7 @@ components: taskId: type: string format: uuid - description: Unique identifier for a task, generated by the system upon task creation + description: 'Unique identifier for a task, generated by the system upon task creation' taskPayload: type: object additionalProperties: true @@ -2547,6 +2548,57 @@ components: - TASK_NOT_FOUND required: - code + jobsPaginatedResponse: + type: object + description: Paginated list of jobs with total count. + required: + - total + - items + additionalProperties: false + properties: + total: + type: integer + minimum: 0 + description: Total number of jobs matching the filter criteria + items: + type: array + description: Page of job records + items: + $ref: '#/components/schemas/job' + stagesPaginatedResponse: + type: object + description: Paginated list of stages with total count. + required: + - total + - items + additionalProperties: false + properties: + total: + type: integer + minimum: 0 + description: Total number of stages matching the filter criteria + items: + type: array + description: Page of stage records + items: + $ref: '#/components/schemas/getStageResponse' + tasksPaginatedResponse: + type: object + description: Paginated list of tasks with total count. + required: + - total + - items + additionalProperties: false + properties: + total: + type: integer + minimum: 0 + description: Total number of tasks matching the filter criteria + items: + type: array + description: Page of task records + items: + $ref: '#/components/schemas/taskResponse' parameters: jobId: in: path @@ -2593,7 +2645,7 @@ components: fromDate: in: query name: from_date - description: Filter results by update time, starting from this date/time + description: 'Filter results by update time, starting from this date/time' required: false schema: type: string @@ -2601,7 +2653,7 @@ components: endDate: in: query name: end_date - description: Filter results by update time, ending at this date/time + description: 'Filter results by update time, ending at this date/time' required: false schema: type: string @@ -2609,14 +2661,14 @@ components: includeStages: in: query name: should_return_stages - description: When true, includes stage data in the response + description: 'When true, includes stage data in the response' required: false schema: $ref: '#/components/schemas/returnStage' includeTasks: in: query name: should_return_tasks - description: When true, includes task data in the response + description: 'When true, includes task data in the response' required: false schema: $ref: '#/components/schemas/returnTask' @@ -2637,7 +2689,7 @@ components: paramStageType: in: query name: stage_type - description: Filter results by stage type (e.g., processing, validation) + description: 'Filter results by stage type (e.g., processing, validation)' required: false schema: $ref: '#/components/schemas/stageType' @@ -2657,3 +2709,24 @@ components: required: false schema: $ref: '#/components/schemas/stageOperationStatusResponse' + pageParam: + in: query + name: page + description: >- + 1-based page number for pagination. Requesting beyond the last page + returns an empty items array. + required: false + schema: + type: integer + minimum: 1 + default: 1 + pageSizeParam: + in: query + name: page_size + description: Number of items to return per page. + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 10 diff --git a/openapi_v1.yaml b/openapi_v1.yaml index bb68de0f..d4d6097a 100644 --- a/openapi_v1.yaml +++ b/openapi_v1.yaml @@ -16,6 +16,8 @@ paths: - $ref: '#/components/parameters/endDate' - $ref: '#/components/parameters/priority' - $ref: '#/components/parameters/includeStages' + - $ref: '#/components/parameters/pageParam' + - $ref: '#/components/parameters/pageSizeParam' summary: Retrieve jobs matching criteria description: | Filter jobs by name, date range, priority. Optional stage inclusion. @@ -28,9 +30,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/job' + $ref: '#/components/schemas/jobsPaginatedResponse' '400': description: Invalid query parameters content: @@ -887,6 +887,8 @@ paths: - $ref: '#/components/parameters/paramStageType' - $ref: '#/components/parameters/stageStatus' - $ref: '#/components/parameters/includeTasks' + - $ref: '#/components/parameters/pageParam' + - $ref: '#/components/parameters/pageSizeParam' summary: Retrieve stages matching specified criteria description: > Returns a filtered list of stages based on the provided query @@ -908,9 +910,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/getStageResponse' + $ref: '#/components/schemas/stagesPaginatedResponse' '400': description: Invalid query parameters content: @@ -1189,15 +1189,16 @@ paths: Provides complete information about each task including type, status, and attempt count. + parameters: + - $ref: '#/components/parameters/pageParam' + - $ref: '#/components/parameters/pageSizeParam' responses: '200': description: Successfully retrieved tasks for the specified stage content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/taskResponse' + $ref: '#/components/schemas/tasksPaginatedResponse' '400': description: Invalid stage ID format or other parameter error content: @@ -1618,6 +1619,8 @@ paths: - $ref: '#/components/parameters/fromDate' - $ref: '#/components/parameters/endDate' - $ref: '#/components/parameters/paramsTaskStatus' + - $ref: '#/components/parameters/pageParam' + - $ref: '#/components/parameters/pageSizeParam' get: operationId: getTasksByCriteria summary: Retrieve tasks matching specified criteria @@ -1639,9 +1642,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/taskResponse' + $ref: '#/components/schemas/tasksPaginatedResponse' '400': description: Invalid query parameters content: @@ -1986,6 +1987,25 @@ components: required: false schema: $ref: '#/components/schemas/stageOperationStatusResponse' + pageParam: + in: query + name: page + description: 1-based page number for pagination. Requesting beyond the last page returns an empty items array. + required: false + schema: + type: integer + minimum: 1 + default: 1 + pageSizeParam: + in: query + name: page_size + description: Number of items to return per page. + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 10 schemas: creationTime: type: string @@ -2657,3 +2677,54 @@ components: - TASK_NOT_FOUND required: - code + jobsPaginatedResponse: + type: object + description: Paginated list of jobs with total count. + required: + - total + - items + additionalProperties: false + properties: + total: + type: integer + minimum: 0 + description: Total number of jobs matching the filter criteria + items: + type: array + description: Page of job records + items: + $ref: '#/components/schemas/job' + stagesPaginatedResponse: + type: object + description: Paginated list of stages with total count. + required: + - total + - items + additionalProperties: false + properties: + total: + type: integer + minimum: 0 + description: Total number of stages matching the filter criteria + items: + type: array + description: Page of stage records + items: + $ref: '#/components/schemas/getStageResponse' + tasksPaginatedResponse: + type: object + description: Paginated list of tasks with total count. + required: + - total + - items + additionalProperties: false + properties: + total: + type: integer + minimum: 0 + description: Total number of tasks matching the filter criteria + items: + type: array + description: Page of task records + items: + $ref: '#/components/schemas/taskResponse' diff --git a/package-lock.json b/package-lock.json index f8fef565..cb992d0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -149,7 +149,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -168,7 +168,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -193,7 +193,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -1277,7 +1277,8 @@ "version": "1.24.0", "resolved": "https://registry.npmjs.org/@map-colonies/schemas/-/schemas-1.24.0.tgz", "integrity": "sha512-W8FY4l5OK0iMmBsAQG5wmTjeseLmTrSlDxNt82m81MU5OFZPXJpKRUhAbs7v7zz1j9vSp5qiTA0pIOkML1wo0Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@map-colonies/semantic-conventions": { "version": "1.0.0", @@ -1450,6 +1451,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -1548,6 +1550,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3641,6 +3644,7 @@ "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18.18" }, @@ -3977,7 +3981,6 @@ "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -4008,15 +4011,13 @@ "version": "0.22.0", "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@redocly/openapi-core": { "version": "1.34.16", "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.16.tgz", "integrity": "sha512-zIgmQTT2TV/U/SJ3N4jlIw36erH6X8ga1UNIoyrlbr0yLEbsiII/16LZ0kMxWu2A8pw0xd56rwTz5sMudy2OAw==", "license": "MIT", - "peer": true, "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", @@ -4037,15 +4038,13 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -4065,7 +4064,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -4078,7 +4076,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -4179,9 +4176,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4199,9 +4193,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4219,9 +4210,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4239,9 +4227,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4259,9 +4244,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4279,9 +4261,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4589,6 +4568,7 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -4645,7 +4625,8 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/lodash": { "version": "4.17.24", @@ -4698,6 +4679,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -4917,6 +4899,7 @@ "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", @@ -5434,6 +5417,7 @@ "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", @@ -5465,6 +5449,7 @@ "integrity": "sha512-CbrXxQpIUMbeyylGlxaxTgnWYM44et4Nx58swl7+vrTyC9Ttx+hQoy8BGCregbbZ08K5qFhRHeoad1VvERUbkg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "^8.58.0", "@typescript-eslint/utils": "^8.58.0" @@ -5594,6 +5579,7 @@ "integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.1.10", "fflate": "^0.8.2", @@ -5652,6 +5638,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5692,6 +5679,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5739,7 +5727,6 @@ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -6057,6 +6044,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -6400,8 +6388,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -6427,6 +6414,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", + "peer": true, "engines": { "node": ">=20" } @@ -6806,6 +6794,7 @@ "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -7260,6 +7249,7 @@ "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -7395,6 +7385,7 @@ "integrity": "sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/types": "^8.56.0", "comment-parser": "^1.4.1", @@ -7667,6 +7658,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8676,7 +8668,6 @@ "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -9102,7 +9093,6 @@ "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9401,9 +9391,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9425,9 +9412,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9449,9 +9433,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9473,9 +9454,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10477,14 +10455,14 @@ "version": "12.1.3", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/openapi-typescript": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", "license": "MIT", - "peer": true, "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", @@ -10505,7 +10483,6 @@ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", @@ -10523,7 +10500,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -11008,6 +10984,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -11125,6 +11102,7 @@ "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", + "peer": true, "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", @@ -11341,6 +11319,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11439,6 +11418,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" @@ -11519,6 +11499,7 @@ "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api": "^1.4.0", "tdigest": "^0.1.1" @@ -12317,7 +12298,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -12514,6 +12495,7 @@ "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", "license": "MIT", + "peer": true, "dependencies": { "cookie-signature": "^1.2.2", "methods": "^1.1.2", @@ -12926,6 +12908,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13085,8 +13068,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/urijs": { "version": "1.19.11", @@ -13148,6 +13130,7 @@ "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", @@ -13226,6 +13209,7 @@ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", @@ -13495,8 +13479,7 @@ "version": "0.0.43", "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/yargs": { "version": "17.7.3", diff --git a/src/api/v1/tasks/controller.ts b/src/api/v1/tasks/controller.ts index 17e5a933..dcdc771f 100644 --- a/src/api/v1/tasks/controller.ts +++ b/src/api/v1/tasks/controller.ts @@ -86,7 +86,7 @@ export class TaskControllerV1 { public getTaskByStageId: TypedRequestHandlers['getTasksByStageIdV1'] = async (req, res, next) => { try { - const response = await this.manager.getTasksByStageId(req.params.stageId); + const response = await this.manager.getTasksByStageId(req.params.stageId, req.query); return res.status(httpStatus.OK).json(response); } catch (err) { if (err instanceof StageNotFoundError) { diff --git a/src/common/utils/pagination.ts b/src/common/utils/pagination.ts new file mode 100644 index 00000000..2dc33f9e --- /dev/null +++ b/src/common/utils/pagination.ts @@ -0,0 +1,41 @@ +import type { Prisma } from '@prismaClient'; + +/** + * Minimal lower bound for a Prisma model delegate. It exists only so `Prisma.Args` / `Prisma.Result` + * can resolve against the concrete delegate inferred at each call site — the real (per-model) types + * are recovered through those operators. + */ +interface PaginableDelegate { + findMany: (args: never) => Prisma.PrismaPromise; + count: (args: never) => Prisma.PrismaPromise; +} + +export const DEFAULT_PAGE = 1; +export const DEFAULT_PAGE_SIZE = 10; + +export interface PaginatedResult { + total: number; + items: T; +} + +/** + * Runs a paginated `findMany` and a matching `count` in parallel and returns `{ total, items }`. + * @param delegate - a Prisma model delegate (`prisma.job`, `prisma.stage`, `prisma.task`, ...) + * @param args - `findMany` args; `take` and `skip` are injected from `page` / `pageSize` + * @param page - 1-based page number (defaults to {@link DEFAULT_PAGE}) + * @param pageSize - number of items per page (defaults to {@link DEFAULT_PAGE_SIZE}) + */ +export async function paginate>( + delegate: Delegate, + args: Args, + page: number = DEFAULT_PAGE, + pageSize: number = DEFAULT_PAGE_SIZE +): Promise>> { + const skip = (page - 1) * pageSize; + const findManyArgs = { ...(args as Record), take: pageSize, skip }; + const countArgs = { where: (args as { where?: unknown }).where }; + + const [items, total] = await Promise.all([delegate.findMany(findManyArgs as never), delegate.count(countArgs as never)]); + + return { total, items: items as Prisma.Result }; +} diff --git a/src/jobs/models/manager.ts b/src/jobs/models/manager.ts index f48053f8..3486586e 100644 --- a/src/jobs/models/manager.ts +++ b/src/jobs/models/manager.ts @@ -14,8 +14,9 @@ import { type PrismaTransaction } from '@src/db/types'; import { resolveTraceContext } from '@src/common/utils/tracingHelpers'; import { IllegalJobStatusTransitionError, JobNotInFiniteStateError, JobNotFoundError } from '@src/common/generated/errors'; import { ATTR_MESSAGING_MESSAGE_CONVERSATION_ID } from '@src/common/semconv'; +import { paginate } from '@src/common/utils/pagination'; import { errorMessages as jobsErrorMessages, SamePriorityChangeError } from './errors'; -import type { JobCreateModel, JobModel, JobFindCriteriaArg, JobPrismaObject } from './models'; +import type { JobCreateModel, JobModel, JobFindCriteriaArg, JobPrismaObject, JobsPaginatedResponse } from './models'; import { jobStateMachine, OperationStatusMapper } from './jobStateMachine'; @injectable() @@ -27,26 +28,26 @@ export class JobManager { ) {} @withSpanAsyncV4 - public async getJobs(params: JobFindCriteriaArg): Promise { - let queryBody = undefined; - - if (params !== undefined) { - queryBody = { - where: { - AND: { - name: { equals: params.job_name }, - priority: { equals: params.priority }, - creationTime: { gte: params.from_date, lte: params.end_date }, - }, - }, - include: { stage: params.should_return_stages }, - }; - } + public async getJobs(params: JobFindCriteriaArg): Promise { + const { page, page_size, ...filterParams } = params ?? {}; + + const where = { + AND: { + name: { equals: filterParams.job_name }, + priority: { equals: filterParams.priority }, + creationTime: { gte: filterParams.from_date, lte: filterParams.end_date }, + }, + }; - const jobs = await this.prisma.job.findMany(queryBody); + const { total, items: jobs } = await paginate( + this.prisma.job, + { where, include: { stage: filterParams.should_return_stages ?? false }, orderBy: { creationTime: 'asc' as const } }, + page, + page_size + ); - const result = jobs.map((job) => this.convertPrismaToJobResponse(job)); - return result; + const items = jobs.map((job) => this.convertPrismaToJobResponse(job)); + return { total, items }; } @withSpanAsyncV4 diff --git a/src/jobs/models/models.ts b/src/jobs/models/models.ts index ad806f54..7fc798c4 100644 --- a/src/jobs/models/models.ts +++ b/src/jobs/models/models.ts @@ -5,6 +5,7 @@ type JobModel = components['schemas']['job']; type JobCreateModel = components['schemas']['createJobPayload']; type JobGetParams = components['parameters']; type JobFindCriteriaArg = operations['findJobsV1']['parameters']['query']; +type JobsPaginatedResponse = components['schemas']['jobsPaginatedResponse']; /** * Generic type for Job Prisma objects with configurable stage inclusion @@ -14,4 +15,4 @@ type JobPrismaObject = Prisma.JobGetPay include: { stage: IncludeStages }; }>; -export type { JobModel, JobCreateModel, JobGetParams, JobFindCriteriaArg, JobPrismaObject }; +export type { JobModel, JobCreateModel, JobGetParams, JobFindCriteriaArg, JobPrismaObject, JobsPaginatedResponse }; diff --git a/src/openapi.d.ts b/src/openapi.d.ts index d881c319..ce6309a0 100644 --- a/src/openapi.d.ts +++ b/src/openapi.d.ts @@ -15,14 +15,12 @@ export type paths = { * Retrieve jobs matching criteria * @description Filter jobs by name, date range, priority. Optional stage inclusion. * Returns empty array if no matches. - * */ get: operations['findJobsV1']; put?: never; /** * Create job * @description Create job with config and metadata. Initial status: PENDING. - * */ post: operations['createJobV1']; delete?: never; @@ -48,7 +46,6 @@ export type paths = { * * Optional inclusion of related stage data via the should_return_stages parameter, * which allows clients to retrieve the complete job hierarchy in a single request. - * */ get: operations['getJobByIdV1']; put?: never; @@ -63,7 +60,6 @@ export type paths = { * Attempting to delete a job in any other state will result in a 400 error. * * Returns a success message with code JOB_DELETED_SUCCESSFULLY when completed. - * */ delete: operations['deleteJobV1']; options?: never; @@ -92,7 +88,6 @@ export type paths = { * * User metadata is useful for storing application-specific context, tracking information, * or any custom data needed by client applications. - * */ patch: operations['updateUserMetadataV1']; trace?: never; @@ -121,7 +116,6 @@ export type paths = { * Higher priority jobs will be processed before lower priority ones when resources * are constrained. Priority changes take effect immediately and apply to all * pending tasks associated with the job. - * */ patch: operations['updateJobPriorityV1']; trace?: never; @@ -150,7 +144,6 @@ export type paths = { * Status changes follow a state machine that enforces valid transitions. When a job's status * is changed, the system will automatically update timestamps and completion percentages as * appropriate. - * */ put: operations['updateStatusV1']; post?: never; @@ -181,7 +174,6 @@ export type paths = { * * Optional inclusion of related task data via the should_return_tasks parameter, * allowing clients to retrieve the complete job hierarchy in a single request. - * */ get: operations['getStagesByJobIdV1']; put?: never; @@ -212,7 +204,6 @@ export type paths = { * The order field ensures stages are processed in the correct sequence when retrieved. * * The job must exist and be in a valid state to accept new stages. - * */ post: operations['addStageV1']; delete?: never; @@ -235,7 +226,6 @@ export type paths = { * * Optional inclusion of related task data via the should_return_tasks parameter * allows clients to retrieve the complete stage hierarchy in a single request. - * */ get: operations['getStagesV1']; put?: never; @@ -263,7 +253,6 @@ export type paths = { * * Optional inclusion of related task data via the should_return_tasks parameter, * which allows clients to retrieve the complete stage hierarchy in a single request. - * */ get: operations['getStageByIdV1']; put?: never; @@ -289,7 +278,6 @@ export type paths = { * * This endpoint is useful for displaying progress indicators or status dashboards * without needing to retrieve and process all individual task details. - * */ get: operations['getStageSummaryV1']; put?: never; @@ -321,7 +309,6 @@ export type paths = { * * User metadata is useful for storing application-specific context, tracking information, * or any custom data needed by client applications. - * */ patch: operations['updateStageUserMetadataV1']; trace?: never; @@ -351,7 +338,6 @@ export type paths = { * Status changes follow a state machine that enforces valid transitions. When a stage's * status changes, it may automatically update the parent job's status and trigger * transitions in subsequent stages (e.g., activating the next stage in the sequence). - * */ put: operations['updateStageStatusV1']; post?: never; @@ -375,7 +361,6 @@ export type paths = { * Retrieve all tasks for a specific stage * @description Fetches all tasks associated with the specified stage ID. * Provides complete information about each task including type, status, and attempt count. - * */ get: operations['getTasksByStageIdV1']; put?: never; @@ -388,7 +373,6 @@ export type paths = { * maximum attempt configuration. Tasks are created with an initial status of PENDING. * * The stage must exist and be in a valid state to accept new tasks. - * */ post: operations['addTasksV1']; delete?: never; @@ -423,7 +407,6 @@ export type paths = { * that are in valid states (PENDING or RETRIED), and updates related stage and job status if needed. * * If successful, returns the complete task details with status updated to IN_PROGRESS. - * */ patch: operations['dequeueTaskV1']; trace?: never; @@ -441,6 +424,10 @@ export type paths = { end_date?: components['parameters']['endDate']; /** @description Filter tasks by their operational status */ status?: components['parameters']['paramsTaskStatus']; + /** @description 1-based page number for pagination. Requesting beyond the last page returns an empty items array. */ + page?: components['parameters']['pageParam']; + /** @description Number of items to return per page. */ + page_size?: components['parameters']['pageSizeParam']; }; header?: never; path?: never; @@ -453,7 +440,6 @@ export type paths = { * * This endpoint is useful for monitoring task progress across multiple stages and jobs, * enabling clients to build custom dashboards or track specific task types. - * */ get: operations['getTasksByCriteriaV1']; put?: never; @@ -478,7 +464,6 @@ export type paths = { * Retrieve a specific task by its ID * @description Fetches detailed information about a task using its unique identifier. * Returns complete task data including type, status, payload, and attempt information. - * */ get: operations['getTaskByIdV1']; put?: never; @@ -510,7 +495,6 @@ export type paths = { * * User metadata is useful for storing application-specific context, tracking information, * or any custom data needed by client applications. - * */ patch: operations['updateTaskUserMetadataV1']; trace?: never; @@ -538,7 +522,6 @@ export type paths = { * Status changes follow a state machine that enforces valid transitions. When a task's status * is changed, the system will automatically update the parent stage's summary statistics and * may affect the stage's overall status. - * */ put: operations['updateTaskStatusV1']; post?: never; @@ -604,7 +587,6 @@ export type components = { /** * @description Job priority for processing order. Higher priority processed first. * VERY_HIGH > HIGH > MEDIUM > LOW > VERY_LOW - * * @example LOW * @enum {string} */ @@ -612,7 +594,6 @@ export type components = { /** * @description W3C traceparent for distributed tracing. Auto-injected if not provided. * See [W3C Trace Context](https://www.w3.org/TR/trace-context/). - * * @example 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 */ traceparent: string; @@ -630,7 +611,6 @@ export type components = { /** * @description User-controllable job statuses: PENDING (resume), ABORTED (cancel), PAUSED (suspend). * System-managed: IN_PROGRESS, COMPLETED, FAILED, CREATED. - * * @example PENDING * @enum {string} */ @@ -638,7 +618,6 @@ export type components = { /** * @description All job states. User-controllable: PENDING, ABORTED, PAUSED. System-managed: IN_PROGRESS, COMPLETED, FAILED, CREATED. * Terminal states: COMPLETED, FAILED, ABORTED. - * * @example PENDING * @enum {string} */ @@ -646,14 +625,12 @@ export type components = { /** * @description User-controllable stage status: PENDING only. * System-managed: IN_PROGRESS, COMPLETED, FAILED, ABORTED, WAITING, CREATED. - * * @enum {string} */ stageOperationStatus: 'PENDING'; /** * @description All stage states. User-controllable: PENDING. System-managed: IN_PROGRESS, COMPLETED, FAILED, ABORTED, WAITING, CREATED. * Terminal states: COMPLETED, FAILED, ABORTED. - * * @example PENDING * @enum {string} */ @@ -661,7 +638,6 @@ export type components = { /** * @description User-controllable task statuses: COMPLETED, FAILED. * System-managed: PENDING, IN_PROGRESS, CREATED, RETRIED. - * * @example COMPLETED * @enum {string} */ @@ -669,7 +645,6 @@ export type components = { /** * @description All task states including RETRIED for retry handling. * Terminal states: COMPLETED, FAILED. - * * @example PENDING * @enum {string} */ @@ -683,7 +658,6 @@ export type components = { * @description Free-form string identifier for stage functionality, allowing flexible categorization * of stage operations. Used for routing tasks to appropriate workers and * for filtering in API requests. Can be any descriptive name up to 50 characters. - * * @example unknown */ stageType: string; @@ -698,10 +672,11 @@ export type components = { userMetadata: { [key: string]: unknown; }; - /** @description Aggregated task statistics grouped by operational status, providing a complete overview of stage progress. + /** + * @description Aggregated task statistics grouped by operational status, providing a complete overview of stage progress. * Used for monitoring progress, generating dashboards, and determining when stages/jobs are complete. * The total field should always equal the sum of all other status counts. - * */ + */ summary: { /** @description Number of tasks awaiting execution */ pending: number; @@ -718,7 +693,8 @@ export type components = { /** @description Total count of tasks belonging to the stage */ total: number; }; - /** @description Input payload for creating a new job in the system. + /** + * @description Input payload for creating a new job in the system. * Contains all required configuration for job execution, including processing mode, * custom parameters, metadata. * @@ -726,7 +702,7 @@ export type components = { * - If traceparent is provided, user's trace context is used (tracestate defaults to null if not provided) * - If traceparent is not provided, the system automatically injects both traceparent and tracestate * from the active OpenTelemetry context using propagation.inject() (tracestate may still be null if not available) - * */ + */ createJobPayload: { name: components['schemas']['jobName']; data: components['schemas']['jobPayload']; @@ -735,7 +711,8 @@ export type components = { traceparent?: components['schemas']['traceparent']; tracestate?: components['schemas']['tracestate']; }; - /** @description Complete job information with status and metadata. + /** + * @description Complete job information with status and metadata. * * Comprehensive job response model containing all job details including configuration, * execution status, progress tracking, and associated metadata. This schema represents @@ -743,7 +720,7 @@ export type components = { * * Includes optional stage data when requested via query parameters, allowing clients * to retrieve the complete job hierarchy in a single request. - * */ + */ job: { id: components['schemas']['jobId']; status: components['schemas']['jobOperationStatusResponse']; @@ -764,19 +741,19 @@ export type components = { * If true, the stage will not start processing immediately and will require * manual intervention to begin execution. Useful for staging workflows where * stages need to be prepared but not executed until all dependencies are met. - * * @example false */ startAsWaiting?: boolean; }; - /** @description Input payload for creating a new stage within a job. + /** + * @description Input payload for creating a new stage within a job. * Contains stage type, operational parameters, and optional user metadata. * * Tracing fields (traceparent, tracestate) are optional: * - If traceparent is provided, user's trace context is used (tracestate defaults to null if not provided) * - If traceparent is not provided, the system automatically injects both traceparent and tracestate * from the active OpenTelemetry context using propagation.inject() (tracestate may still be null if not available) - * */ + */ createStagePayload: { type: components['schemas']['stageType']; data: components['schemas']['stagePayload']; @@ -802,14 +779,16 @@ export type components = { * @description Unique identifier for a task, generated by the system upon task creation */ taskId: string; - /** @description Custom task configuration data containing operation-specific parameters. + /** + * @description Custom task configuration data containing operation-specific parameters. * The schema varies based on task type and contains all necessary information * for task execution by workers. - * */ + */ taskPayload: { [key: string]: unknown; }; - /** @description Input payload for creating a new task within a stage. + /** + * @description Input payload for creating a new task within a stage. * Contains task type, operational parameters, and optional retry configuration. * Used when adding tasks to existing stages. * @@ -817,7 +796,7 @@ export type components = { * - If traceparent is provided, user's trace context is used (tracestate defaults to null if not provided) * - If traceparent is not provided, the system automatically injects both traceparent and tracestate * from the active OpenTelemetry context using propagation.inject() (tracestate may still be null if not available) - * */ + */ createTaskPayload: { data: components['schemas']['taskPayload']; userMetadata?: components['schemas']['userMetadata']; @@ -825,10 +804,11 @@ export type components = { traceparent?: components['schemas']['traceparent']; tracestate?: components['schemas']['tracestate']; }; - /** @description Complete task information returned by the API, including all configuration + /** + * @description Complete task information returned by the API, including all configuration * data along with execution status, attempt tracking, and associated stage reference. * Used when retrieving task details or after task creation. - * */ + */ taskResponse: { id: components['schemas']['taskId']; data: components['schemas']['taskPayload']; @@ -844,16 +824,18 @@ export type components = { traceparent: components['schemas']['traceparent']; tracestate?: components['schemas']['tracestate']; }; - /** @description Standard success response structure used for operations that don't + /** + * @description Standard success response structure used for operations that don't * return entity data, providing a standardized confirmation message. - * */ + */ defaultOkMessage: { code: components['schemas']['successMessages']; }; - /** @description Foundation schema for standardized error responses across all API endpoints. + /** + * @description Foundation schema for standardized error responses across all API endpoints. * Provides consistent structure with human-readable message and common-readable code fields * that are extended by individual endpoints to define their specific error scenarios. - * */ + */ baseErrorResponse: { /** @description Human-readable error message describing the issue */ message: string; @@ -880,6 +862,27 @@ export type components = { /** @enum {unknown} */ code: 'TASK_NOT_FOUND'; }; + /** @description Paginated list of jobs with total count. */ + jobsPaginatedResponse: { + /** @description Total number of jobs matching the filter criteria */ + total: number; + /** @description Page of job records */ + items: components['schemas']['job'][]; + }; + /** @description Paginated list of stages with total count. */ + stagesPaginatedResponse: { + /** @description Total number of stages matching the filter criteria */ + total: number; + /** @description Page of stage records */ + items: components['schemas']['getStageResponse'][]; + }; + /** @description Paginated list of tasks with total count. */ + tasksPaginatedResponse: { + /** @description Total number of tasks matching the filter criteria */ + total: number; + /** @description Page of task records */ + items: components['schemas']['taskResponse'][]; + }; }; responses: never; parameters: { @@ -911,10 +914,15 @@ export type components = { paramStageType: components['schemas']['stageType']; /** @description Stage type identifier for dequeuing tasks */ stageType: components['schemas']['stageType']; - /** @description Filter results by stage operational status (e.g., PENDING, IN_PROGRESS). + /** + * @description Filter results by stage operational status (e.g., PENDING, IN_PROGRESS). * Used to find stages in specific execution states. - * */ + */ stageStatus: components['schemas']['stageOperationStatusResponse']; + /** @description 1-based page number for pagination. Requesting beyond the last page returns an empty items array. */ + pageParam: number; + /** @description Number of items to return per page. */ + pageSizeParam: number; }; requestBodies: never; headers: never; @@ -935,6 +943,10 @@ export interface operations { priority?: components['parameters']['priority']; /** @description When true, includes stage data in the response */ should_return_stages?: components['parameters']['includeStages']; + /** @description 1-based page number for pagination. Requesting beyond the last page returns an empty items array. */ + page?: components['parameters']['pageParam']; + /** @description Number of items to return per page. */ + page_size?: components['parameters']['pageSizeParam']; }; header?: never; path?: never; @@ -948,7 +960,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['job'][]; + 'application/json': components['schemas']['jobsPaginatedResponse']; }; }; /** @description Invalid query parameters */ @@ -1084,9 +1096,11 @@ export interface operations { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "code": "JOB_DELETED_SUCCESSFULLY" - * } */ + * } + */ 'application/json': components['schemas']['defaultOkMessage']; }; }; @@ -1144,9 +1158,11 @@ export interface operations { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "code": "JOB_MODIFIED_SUCCESSFULLY" - * } */ + * } + */ 'application/json': components['schemas']['defaultOkMessage']; }; }; @@ -1203,9 +1219,11 @@ export interface operations { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "code": "JOB_MODIFIED_SUCCESSFULLY" - * } */ + * } + */ 'application/json': components['schemas']['defaultOkMessage']; }; }; @@ -1271,9 +1289,11 @@ export interface operations { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "code": "JOB_MODIFIED_SUCCESSFULLY" - * } */ + * } + */ 'application/json': components['schemas']['defaultOkMessage']; }; }; @@ -1426,12 +1446,17 @@ export interface operations { job_id?: components['parameters']['paramJobId']; /** @description Filter results by stage type (e.g., processing, validation) */ stage_type?: components['parameters']['paramStageType']; - /** @description Filter results by stage operational status (e.g., PENDING, IN_PROGRESS). + /** + * @description Filter results by stage operational status (e.g., PENDING, IN_PROGRESS). * Used to find stages in specific execution states. - * */ + */ stage_operation_status?: components['parameters']['stageStatus']; /** @description When true, includes task data in the response */ should_return_tasks?: components['parameters']['includeTasks']; + /** @description 1-based page number for pagination. Requesting beyond the last page returns an empty items array. */ + page?: components['parameters']['pageParam']; + /** @description Number of items to return per page. */ + page_size?: components['parameters']['pageSizeParam']; }; header?: never; path?: never; @@ -1445,7 +1470,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['getStageResponse'][]; + 'application/json': components['schemas']['stagesPaginatedResponse']; }; }; /** @description Invalid query parameters */ @@ -1593,9 +1618,11 @@ export interface operations { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "code": "STAGE_MODIFIED_SUCCESSFULLY" - * } */ + * } + */ 'application/json': components['schemas']['defaultOkMessage']; }; }; @@ -1652,9 +1679,11 @@ export interface operations { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "code": "STAGE_MODIFIED_SUCCESSFULLY" - * } */ + * } + */ 'application/json': components['schemas']['defaultOkMessage']; }; }; @@ -1695,7 +1724,12 @@ export interface operations { }; getTasksByStageIdV1: { parameters: { - query?: never; + query?: { + /** @description 1-based page number for pagination. Requesting beyond the last page returns an empty items array. */ + page?: components['parameters']['pageParam']; + /** @description Number of items to return per page. */ + page_size?: components['parameters']['pageSizeParam']; + }; header?: never; path: { /** @description Unique identifier for the stage */ @@ -1711,7 +1745,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['taskResponse'][]; + 'application/json': components['schemas']['tasksPaginatedResponse']; }; }; /** @description Invalid stage ID format or other parameter error */ @@ -1839,7 +1873,7 @@ export interface operations { 'application/json': components['schemas']['taskNotFoundResponse']; }; }; - /** @description Race condition detected: task was claimed by another worker. This occurs when multiple workers attempt to dequeue the same task simultaneously. The client should retry the dequeue operation to get a different task. */ + /** @description task was claimed by another worker. This occurs when multiple workers attempt to dequeue the same task simultaneously. The client should retry the dequeue operation to get a different task. */ 409: { headers: { [name: string]: unknown; @@ -1886,6 +1920,10 @@ export interface operations { end_date?: components['parameters']['endDate']; /** @description Filter tasks by their operational status */ status?: components['parameters']['paramsTaskStatus']; + /** @description 1-based page number for pagination. Requesting beyond the last page returns an empty items array. */ + page?: components['parameters']['pageParam']; + /** @description Number of items to return per page. */ + page_size?: components['parameters']['pageSizeParam']; }; header?: never; path?: never; @@ -1899,7 +1937,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['taskResponse'][]; + 'application/json': components['schemas']['tasksPaginatedResponse']; }; }; /** @description Invalid query parameters */ @@ -1994,9 +2032,11 @@ export interface operations { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "code": "TASK_MODIFIED_SUCCESSFULLY" - * } */ + * } + */ 'application/json': components['schemas']['defaultOkMessage']; }; }; @@ -2077,7 +2117,7 @@ export interface operations { 'application/json': components['schemas']['taskNotFoundResponse']; }; }; - /** @description Race condition detected: task status was modified by another request. This occurs when multiple workers attempt to update the same task simultaneously. The current state of the task has changed since it was retrieved. */ + /** @description task status was modified by another request. This occurs when multiple workers attempt to update the same task simultaneously. The current state of the task has changed since it was retrieved. */ 409: { headers: { [name: string]: unknown; diff --git a/src/stages/models/manager.ts b/src/stages/models/manager.ts index 81ee40f8..ae087a22 100644 --- a/src/stages/models/manager.ts +++ b/src/stages/models/manager.ts @@ -16,6 +16,7 @@ import { IllegalStageStatusTransitionError, JobInFiniteStateError, JobNotFoundEr import { errorMessages as stagesErrorMessages } from '@src/stages/models/errors'; import type { PrismaTransaction } from '@src/db/types'; import { ATTR_MESSAGING_DESTINATION_NAME, ATTR_MESSAGING_MESSAGE_CONVERSATION_ID } from '@src/common/semconv'; +import { paginate } from '@src/common/utils/pagination'; import { StageRepository } from '../DAL/stageRepository'; import type { StageCreateModel, @@ -24,6 +25,7 @@ import type { StageIncludingJob, StageModel, StageSummary, + StagesPaginatedResponse, UpdateSummaryCount, } from './models'; import { @@ -127,25 +129,26 @@ export class StageManager { } @withSpanAsyncV4 - public async getStages(params: StageFindCriteriaArg): Promise { - let queryBody = undefined; - if (params !== undefined) { - queryBody = { - where: { - AND: { - jobId: { equals: params.job_id }, - type: { equals: params.stage_type }, - status: { equals: params.stage_operation_status }, - }, - }, - include: { task: params.should_return_tasks }, - }; - } + public async getStages(params: StageFindCriteriaArg): Promise { + const { page, page_size, ...filterParams } = params ?? {}; + + const where = { + AND: { + jobId: { equals: filterParams.job_id }, + type: { equals: filterParams.stage_type }, + status: { equals: filterParams.stage_operation_status }, + }, + }; - const stages = await this.prisma.stage.findMany(queryBody); + const { total, items: stages } = await paginate( + this.prisma.stage, + { where, include: { task: filterParams.should_return_tasks ?? false }, orderBy: { id: 'asc' as const } }, + page, + page_size + ); - const result = convertArrayPrismaStageToStageResponse(stages); - return result; + const items = convertArrayPrismaStageToStageResponse(stages); + return { total, items }; } @withSpanAsyncV4 diff --git a/src/stages/models/models.ts b/src/stages/models/models.ts index c12eb8c9..9395e511 100644 --- a/src/stages/models/models.ts +++ b/src/stages/models/models.ts @@ -9,6 +9,7 @@ type StageCreateModel = components['schemas']['createStagePayloadRequest']; type StageCreateBody = StageCreateModel & { jobId: string; xstate: Snapshot }; type StageSummary = components['schemas']['summary']; type StageFindCriteriaArg = operations['getStagesV1']['parameters']['query']; +type StagesPaginatedResponse = components['schemas']['stagesPaginatedResponse']; type StageIncludingJob = StagePrismaObject & { job: JobPrismaObject }; interface UpdateSummaryCount { add: { status: TaskOperationStatus; count: number }; @@ -45,4 +46,5 @@ export type { UpdateSummaryCount, StageIncludingJob, StageEntityOptions, + StagesPaginatedResponse, }; diff --git a/src/tasks/models/manager.ts b/src/tasks/models/manager.ts index 381ea00a..e0fcd378 100644 --- a/src/tasks/models/manager.ts +++ b/src/tasks/models/manager.ts @@ -24,8 +24,9 @@ import { TaskStatusUpdateFailedError, } from '@src/common/generated/errors'; import { ATTR_MESSAGING_DESTINATION_NAME, ATTR_MESSAGING_MESSAGE_ID } from '@src/common/semconv'; +import { paginate } from '@src/common/utils/pagination'; import { TaskRepository } from '../DAL/taskRepository'; -import type { TasksFindCriteriaArg, TaskModel, TaskPrismaObject, TaskCreateModel } from './models'; +import type { TasksFindCriteriaArg, TaskModel, TaskPrismaObject, TaskCreateModel, TasksPaginatedResponse, TasksByStageIdQuery } from './models'; import { errorMessages as tasksErrorMessages } from './errors'; import { convertArrayPrismaTaskToTaskResponse, convertPrismaToTaskResponse } from './helper'; @@ -117,24 +118,24 @@ export class TaskManager { * @returns Promise resolving to array of task models */ @withSpanAsyncV4 - public async getTasks(params: TasksFindCriteriaArg): Promise { - const hasNoParams = params === undefined || Object.keys(params).length === 0; - - const queryBody: Prisma.TaskFindManyArgs = { - where: hasNoParams - ? undefined - : { - AND: { - stageId: { equals: params.stage_id }, - stage: { type: { equals: params.stage_type } }, - status: { equals: params.status }, - creationTime: { gte: params.from_date, lte: params.end_date }, - }, + public async getTasks(params: TasksFindCriteriaArg): Promise { + const { page, page_size, ...filterParams } = params ?? {}; + const hasNoFilters = Object.keys(filterParams).length === 0; + + const where: Prisma.TaskWhereInput | undefined = hasNoFilters + ? undefined + : { + AND: { + stageId: { equals: filterParams.stage_id }, + stage: { type: { equals: filterParams.stage_type } }, + status: { equals: filterParams.status }, + creationTime: { gte: filterParams.from_date, lte: filterParams.end_date }, }, - }; + }; - const tasks = await this.prisma.task.findMany(queryBody); - return convertArrayPrismaTaskToTaskResponse(tasks); + const { total, items: tasks } = await paginate(this.prisma.task, { where, orderBy: { creationTime: 'asc' as const } }, page, page_size); + + return { total, items: convertArrayPrismaTaskToTaskResponse(tasks) }; } @withSpanAsyncV4 @@ -154,7 +155,7 @@ export class TaskManager { } @withSpanAsyncV4 - public async getTasksByStageId(stageId: string): Promise { + public async getTasksByStageId(stageId: string, query: TasksByStageIdQuery): Promise { const spanActive = trace.getActiveSpan(); spanActive?.setAttributes({ [INFRA_CONVENTIONS.infra.jobnik.stage.id]: stageId, @@ -162,15 +163,15 @@ export class TaskManager { // To validate existence of stage, if not will throw StageNotFoundError await this.stageManager.getStageById(stageId); - const queryBody = { - where: { - stageId, - }, - }; + const { total, items: tasks } = await paginate( + this.prisma.task, + { where: { stageId }, orderBy: { creationTime: 'asc' as const } }, + query?.page, + query?.page_size + ); - const stages = await this.prisma.task.findMany(queryBody); - const result = stages.map((stage) => convertPrismaToTaskResponse(stage)); - return result; + const items = tasks.map((task) => convertPrismaToTaskResponse(task)); + return { total, items }; } @withSpanAsyncV4 diff --git a/src/tasks/models/models.ts b/src/tasks/models/models.ts index bb2e7f98..c9da4791 100644 --- a/src/tasks/models/models.ts +++ b/src/tasks/models/models.ts @@ -5,5 +5,7 @@ type TaskModel = components['schemas']['taskResponse']; type TaskCreateModel = components['schemas']['createTaskPayload']; type TasksFindCriteriaArg = operations['getTasksByCriteriaV1']['parameters']['query']; type TaskPrismaObject = Prisma.TaskGetPayload; +type TasksPaginatedResponse = components['schemas']['tasksPaginatedResponse']; +type TasksByStageIdQuery = operations['getTasksByStageIdV1']['parameters']['query']; -export type { TaskModel, TaskCreateModel, TasksFindCriteriaArg, TaskPrismaObject }; +export type { TaskModel, TaskCreateModel, TasksFindCriteriaArg, TaskPrismaObject, TasksPaginatedResponse, TasksByStageIdQuery }; diff --git a/tests/integration/common/utils.ts b/tests/integration/common/utils.ts index 53cc271d..8941ba39 100644 --- a/tests/integration/common/utils.ts +++ b/tests/integration/common/utils.ts @@ -91,6 +91,16 @@ export async function createJobnikTree( return { job, stage, tasks }; } +/** + * Wipes all job/stage/task rows before a test runs. + * Integration test files share one physical database, so without this a test can observe + * rows left behind by a previous test (e.g. `cleanStaleTasks` or unscoped "list all" queries + * picking up unrelated leftover data), causing flaky assertions. + */ +export async function truncateAllTables(prisma: PrismaClient): Promise { + await prisma.$executeRaw`TRUNCATE TABLE "job_manager"."job" RESTART IDENTITY CASCADE`; +} + /** * Creates a mock Prisma error for testing purposes. * Default message is 'Database error'. diff --git a/tests/integration/docs/docs.spec.ts b/tests/integration/docs/docs.spec.ts index 1221ab7b..38c8d21b 100644 --- a/tests/integration/docs/docs.spec.ts +++ b/tests/integration/docs/docs.spec.ts @@ -6,6 +6,7 @@ import type { PrismaClient } from '@prismaClient'; import { getApp } from '@src/app'; import { SERVICES } from '@src/common/constants'; import { initConfig } from '@src/common/config'; +import { truncateAllTables } from '../common/utils'; import { DocsRequestSender } from './helpers/docsRequestSender'; describe('docs', function () { @@ -27,6 +28,7 @@ describe('docs', function () { requestSender = new DocsRequestSender(app); prisma = container.resolve(SERVICES.PRISMA); + await truncateAllTables(prisma); }); afterEach(async function () { diff --git a/tests/integration/jobs/jobs.spec.ts b/tests/integration/jobs/jobs.spec.ts index 5d22d32a..71813465 100644 --- a/tests/integration/jobs/jobs.spec.ts +++ b/tests/integration/jobs/jobs.spec.ts @@ -23,7 +23,7 @@ import type { JobCreateModel } from '@src/jobs/models/models'; import { DEFAULT_TRACEPARENT } from '@src/common/utils/tracingHelpers'; import { illegalStatusTransitionErrorMessage } from '@src/common/errors'; import { createProxyMock } from '@tests/configurations/mockPrisma'; -import { createJobnikTree, createMockPrismaError, createMockUnknownDbError } from '../common/utils'; +import { createJobnikTree, createMockPrismaError, createMockUnknownDbError, truncateAllTables } from '../common/utils'; import { createJobRecord, createJobRequestBody, testJobId } from './helpers'; const memoryExporter = new InMemorySpanExporter(); @@ -52,6 +52,7 @@ describe('job', function () { requestSender = await createRequestSender('openapi3.yaml', app); prisma = container.resolve(SERVICES.PRISMA); + await truncateAllTables(prisma); }); afterEach(async function () { @@ -79,9 +80,12 @@ describe('job', function () { expectResponseStatus(response, 200); expect(response).toSatisfyApiSpec(); - expect(response.body).toBeArray(); - expect(response.body).not.toHaveLength(0); - expect(response.body[0]).toHaveProperty('stages'); + + const jobsBody = response.body; + + expect(jobsBody.items).toBeArray(); + expect(jobsBody.items).not.toHaveLength(0); + expect(jobsBody.items[0]).toHaveProperty('stages'); }); it('should return 200 status code and the matching job with stages when stages flag is false', async function () { @@ -96,8 +100,11 @@ describe('job', function () { expectResponseStatus(response, 200); expect(response).toSatisfyApiSpec(); - expect(response.body[0]).toMatchObject(jobRequestBody); - expect(response.body[0]).not.toHaveProperty('stages'); + + const jobsBodyNoStages = response.body; + + expect(jobsBodyNoStages.items[0]).toMatchObject(jobRequestBody); + expect(jobsBodyNoStages.items[0]).not.toHaveProperty('stages'); }); it('should return 200 status code and return the job without stages when stages flag is omitted', async function () { @@ -111,8 +118,41 @@ describe('job', function () { expectResponseStatus(response, 200); expect(response).toSatisfyApiSpec(); - expect(response.body[0]).toMatchObject(jobRequestBody); - expect(response.body[0]).not.toHaveProperty('stages'); + + const jobsBodyOmitted = response.body; + + expect(jobsBodyOmitted.items[0]).toMatchObject(jobRequestBody); + expect(jobsBodyOmitted.items[0]).not.toHaveProperty('stages'); + }); + + it('should return 200 with total and items when paginating', async function () { + await requestSender.createJobV1({ requestBody: createJobRequestBody }); + await requestSender.createJobV1({ requestBody: createJobRequestBody }); + + const response = await requestSender.findJobsV1({ queryParams: { page: 1, page_size: 1 } }); + + expectResponseStatus(response, 200); + + expect(response).toSatisfyApiSpec(); + + const paginatedJobsBody = response.body; + + expect(paginatedJobsBody.total).toBeGreaterThanOrEqual(2); + expect(paginatedJobsBody.items).toHaveLength(1); + }); + + it('should return 200 with empty items array when page is beyond total', async function () { + await requestSender.createJobV1({ requestBody: createJobRequestBody }); + + const response = await requestSender.findJobsV1({ queryParams: { page: 9999, page_size: 10 } }); + + expectResponseStatus(response, 200); + + expect(response).toSatisfyApiSpec(); + + const beyondPageJobsBody = response.body; + + expect(beyondPageJobsBody.items).toHaveLength(0); }); }); diff --git a/tests/integration/stages/stages.spec.ts b/tests/integration/stages/stages.spec.ts index 115a14ff..a3b72df8 100644 --- a/tests/integration/stages/stages.spec.ts +++ b/tests/integration/stages/stages.spec.ts @@ -18,7 +18,7 @@ import { getApp } from '@src/app'; import { SERVICES } from '@common/constants'; import { initConfig } from '@src/common/config'; import { errorMessages as jobsErrorMessages } from '@src/jobs/models/errors'; -import type { StageCreateModel, StageModel } from '@src/stages/models/models'; +import type { StageCreateModel, StageModel, StagesPaginatedResponse } from '@src/stages/models/models'; import { errorMessages as stagesErrorMessages } from '@src/stages/models/errors'; import { defaultStatusCounts } from '@src/stages/models/helper'; import { @@ -33,7 +33,7 @@ import { DEFAULT_TRACEPARENT } from '@src/common/utils/tracingHelpers'; import { illegalStatusTransitionErrorMessage } from '@src/common/errors'; import { createProxyMock } from '@tests/configurations/mockPrisma'; import { createJobRecord, createJobRequestBody, testJobId, testStageId } from '../jobs/helpers'; -import { createJobnikTree, createMockPrismaError, createMockUnknownDbError } from '../common/utils'; +import { createJobnikTree, createMockPrismaError, createMockUnknownDbError, truncateAllTables } from '../common/utils'; const expectResponseStatus: ExpectResponseStatus = expectResponseStatusFactory(expect); @@ -63,6 +63,7 @@ describe('stage', function () { requestSender = await createRequestSender('openapi3.yaml', app); prisma = container.resolve(SERVICES.PRISMA); + await truncateAllTables(prisma); // Ensure Prisma methods exist before they can be spied on (Vitest 4.0 requirement) }); @@ -85,15 +86,18 @@ describe('stage', function () { expect(response).toMatchObject({ status: StatusCodes.OK, - body: [ - { - jobId, - status: StageOperationStatus.CREATED, - type: 'SOME_STAGE_TYPE', - data: { avi: 'is the best' }, - order: 1, - }, - ], + body: { + total: 1, + items: [ + { + jobId, + status: StageOperationStatus.CREATED, + type: 'SOME_STAGE_TYPE', + data: { avi: 'is the best' }, + order: 1, + }, + ], + }, }); }); @@ -103,7 +107,7 @@ describe('stage', function () { expect(response).toSatisfyApiSpec(); expect(response).toMatchObject({ status: StatusCodes.OK, - body: [], + body: { total: 0, items: [] }, }); }); @@ -114,8 +118,41 @@ describe('stage', function () { expect(response).toSatisfyApiSpec(); expect(response).toHaveProperty('status', StatusCodes.OK); - expect(response.body).toBeArray(); - expect(response.body).not.toHaveLength(0); + + const stagesBody = response.body as StagesPaginatedResponse; + + expect(stagesBody.items).toBeArray(); + expect(stagesBody.items).not.toHaveLength(0); + }); + + it('should return 200 with total and items when paginating', async function () { + await createJobnikTree(prisma, {}, {}, [], { createStage: true, createTasks: false }); + await createJobnikTree(prisma, {}, {}, [], { createStage: true, createTasks: false }); + + const response = await requestSender.getStagesV1({ queryParams: { page: 1, page_size: 1 } }); + + expectResponseStatus(response, 200); + + expect(response).toSatisfyApiSpec(); + + const paginatedBody = response.body; + + expect(paginatedBody.total).toBeGreaterThanOrEqual(2); + expect(paginatedBody.items).toHaveLength(1); + }); + + it('should return 200 with empty items array when page is beyond total', async function () { + await createJobnikTree(prisma, {}, {}, [], { createStage: true, createTasks: false }); + + const response = await requestSender.getStagesV1({ queryParams: { page: 9999, page_size: 10 } }); + + expectResponseStatus(response, 200); + + expect(response).toSatisfyApiSpec(); + + const beyondPageBody = response.body; + + expect(beyondPageBody.items).toHaveLength(0); }); it('should return 200 status code and the matching stage with related tasks', async function () { @@ -137,10 +174,15 @@ describe('stage', function () { expect(response).toSatisfyApiSpec(); expect(response).toMatchObject({ status: StatusCodes.OK, - body: [{ jobId: job.id, status: StageOperationStatus.PENDING, type: stage.type, data: stage.data, userMetadata: stage.userMetadata }], + body: { + total: 1, + items: [{ jobId: job.id, status: StageOperationStatus.PENDING, type: stage.type, data: stage.data, userMetadata: stage.userMetadata }], + }, }); - expect(response.body).toMatchObject([{ tasks: [{ data: tasks[0]!.data, userMetadata: tasks[0]!.userMetadata }] }]); + const stagesBodyWithTasks = response.body as StagesPaginatedResponse; + + expect(stagesBodyWithTasks.items).toMatchObject([{ tasks: [{ data: tasks[0]!.data, userMetadata: tasks[0]!.userMetadata }] }]); }); it('should return 200 status code and the matching stage without related tasks', async function () { @@ -162,10 +204,15 @@ describe('stage', function () { expect(response).toSatisfyApiSpec(); expect(response).toMatchObject({ status: StatusCodes.OK, - body: [{ jobId: job.id, status: StageOperationStatus.PENDING, type: stage.type, data: stage.data, userMetadata: stage.userMetadata }], + body: { + total: 1, + items: [{ jobId: job.id, status: StageOperationStatus.PENDING, type: stage.type, data: stage.data, userMetadata: stage.userMetadata }], + }, }); - expect(response.body[0]).not.toHaveProperty('tasks'); + const stagesBodyNoTasks = response.body; + + expect(stagesBodyNoTasks.items[0]).not.toHaveProperty('tasks'); }); }); diff --git a/tests/integration/tasks/tasks.spec.ts b/tests/integration/tasks/tasks.spec.ts index 5c816709..a8907212 100644 --- a/tests/integration/tasks/tasks.spec.ts +++ b/tests/integration/tasks/tasks.spec.ts @@ -19,7 +19,7 @@ import { SERVICES } from '@common/constants'; import { initConfig } from '@src/common/config'; import { errorMessages as tasksErrorMessages } from '@src/tasks/models/errors'; import { errorMessages as stagesErrorMessages } from '@src/stages/models/errors'; -import type { TaskCreateModel, TaskModel } from '@src/tasks/models/models'; +import type { TaskCreateModel, TaskModel, TasksPaginatedResponse } from '@src/tasks/models/models'; import { defaultStatusCounts } from '@src/stages/models/helper'; import { abortedStageXstatePersistentSnapshot, @@ -33,7 +33,7 @@ import { illegalStatusTransitionErrorMessage } from '@src/common/errors'; import { createProxyMock } from '@tests/configurations/mockPrisma'; import { createJobRequestBody } from '../jobs/helpers'; import { addJobRecord, addStageRecord, createStageBody } from '../stages/helpers'; -import { createJobnikTree, createMockPrismaError, createMockUnknownDbError } from '../common/utils'; +import { createJobnikTree, createMockPrismaError, createMockUnknownDbError, truncateAllTables } from '../common/utils'; import { createTaskBody, createTaskRecords } from './helpers'; const expectResponseStatus: ExpectResponseStatus = expectResponseStatusFactory(expect); @@ -62,6 +62,7 @@ describe('task', function () { requestSender = await createRequestSender('openapi3.yaml', app); prisma = container.resolve(SERVICES.PRISMA); + await truncateAllTables(prisma); // Ensure Prisma methods exist before they can be spied on (Vitest 4.0 requirement) }); @@ -83,7 +84,7 @@ describe('task', function () { expect(response).toMatchObject({ status: StatusCodes.OK, - body: [{ id: taskId, stageId }], + body: { total: 1, items: [{ id: taskId, stageId }] }, }); }); @@ -94,7 +95,7 @@ describe('task', function () { expect(response).toSatisfyApiSpec(); expect(response).toMatchObject({ status: StatusCodes.OK, - body: [], + body: { total: 0, items: [] }, }); }); @@ -107,8 +108,40 @@ describe('task', function () { expect(response).toSatisfyApiSpec(); expect(response).toHaveProperty('status', StatusCodes.OK); - expect(response.body).toBeArray(); - expect(response.body).not.toHaveLength(0); + + const allTasksBody = response.body; + + expect(allTasksBody.items).toBeArray(); + expect(allTasksBody.items).not.toHaveLength(0); + }); + + it('should return 200 with total and items when paginating', async function () { + await createJobnikTree(prisma, {}, {}, [{}, {}, {}]); + + const response = await requestSender.getTasksByCriteriaV1({ queryParams: { page: 1, page_size: 2 } }); + + expectResponseStatus(response, 200); + + expect(response).toSatisfyApiSpec(); + + const pagedTasksBody = response.body; + + expect(pagedTasksBody.total).toBeGreaterThanOrEqual(3); + expect(pagedTasksBody.items).toHaveLength(2); + }); + + it('should return 200 with empty items array when page is beyond total', async function () { + await createJobnikTree(prisma, {}, {}, [{}]); + + const response = await requestSender.getTasksByCriteriaV1({ queryParams: { page: 9999, page_size: 10 } }); + + expectResponseStatus(response, 200); + + expect(response).toSatisfyApiSpec(); + + const beyondPageTasksBody = response.body; + + expect(beyondPageTasksBody.items).toHaveLength(0); }); }); @@ -257,13 +290,19 @@ describe('task', function () { const getTasksResponse = await requestSender.getTasksByStageIdV1({ pathParams: { stageId } }); expect(getTasksResponse).toSatisfyApiSpec(); - expect(getTasksResponse.body).toHaveLength(2); + + const tasksBody = getTasksResponse.body as TasksPaginatedResponse; + + expect(tasksBody.total).toBe(2); expect(getTasksResponse).toMatchObject({ status: StatusCodes.OK, - body: [ - { status: TaskOperationStatus.CREATED, stageId }, - { status: TaskOperationStatus.CREATED, stageId }, - ], + body: { + total: 2, + items: [ + { status: TaskOperationStatus.CREATED, stageId }, + { status: TaskOperationStatus.CREATED, stageId }, + ], + }, }); }); @@ -276,9 +315,37 @@ describe('task', function () { expect(getTaskResponse).toSatisfyApiSpec(); expect(getTaskResponse).toMatchObject({ status: StatusCodes.OK, - body: [], + body: { total: 0, items: [] }, }); }); + + it('should return 200 with total and items when paginating', async function () { + const { stage } = await createJobnikTree(prisma, {}, {}, [{}, {}, {}]); + const stageId = stage.id; + + const response = await requestSender.getTasksByStageIdV1({ pathParams: { stageId }, queryParams: { page: 1, page_size: 2 } }); + + expect(response).toSatisfyApiSpec(); + + const paginatedTasks = response.body as TasksPaginatedResponse; + + expect(paginatedTasks.total).toBe(3); + expect(paginatedTasks.items).toHaveLength(2); + }); + + it('should return 200 with empty items array when page is beyond total', async function () { + const { stage } = await createJobnikTree(prisma, {}, {}, [{}]); + const stageId = stage.id; + + const response = await requestSender.getTasksByStageIdV1({ pathParams: { stageId }, queryParams: { page: 9999, page_size: 10 } }); + + expect(response).toSatisfyApiSpec(); + + const beyondPageTasks = response.body as TasksPaginatedResponse; + + expect(beyondPageTasks.total).toBe(1); + expect(beyondPageTasks.items).toHaveLength(0); + }); }); describe('Bad Path', function () { diff --git a/tests/integration/tasksSweeper/tasksSweeper.spec.ts b/tests/integration/tasksSweeper/tasksSweeper.spec.ts index 817879c0..6f9cb6d8 100644 --- a/tests/integration/tasksSweeper/tasksSweeper.spec.ts +++ b/tests/integration/tasksSweeper/tasksSweeper.spec.ts @@ -10,7 +10,7 @@ import { inProgressStageXstatePersistentSnapshot } from '@tests/unit/data'; import { defaultStatusCounts } from '@src/stages/models/helper'; import { TaskManager } from '@src/tasks/models/manager'; import { createProxyMock } from '@tests/configurations/mockPrisma'; -import { createJobnikTree } from '../common/utils'; +import { createJobnikTree, truncateAllTables } from '../common/utils'; describe('TaskSweeper', () => { let prisma: PrismaClient; @@ -31,6 +31,7 @@ describe('TaskSweeper', () => { prisma = container.resolve(SERVICES.PRISMA); taskManager = container.resolve(TaskManager); + await truncateAllTables(prisma); // Ensure Prisma methods exist before they can be spied on (Vitest 4.0 requirement) }); diff --git a/tests/unit/jobs/jobs.spec.ts b/tests/unit/jobs/jobs.spec.ts index 48aa5832..961ffddc 100644 --- a/tests/unit/jobs/jobs.spec.ts +++ b/tests/unit/jobs/jobs.spec.ts @@ -71,8 +71,9 @@ describe('JobManager', () => { it('should return formatted jobs matching the search criteria', async function () { const mediumPriorityJob = { ...jobEntityWithoutStages, priority: Priority.MEDIUM }; prisma.job.findMany.mockResolvedValue([mediumPriorityJob]); + prisma.job.count.mockResolvedValue(1); - const jobs = await jobManager.getJobs({ priority: Priority.MEDIUM }); + const result = await jobManager.getJobs({ priority: Priority.MEDIUM }); const { xstate, stage, ...rest } = mediumPriorityJob; const expectedJob = [ @@ -85,19 +86,22 @@ describe('JobManager', () => { }, ]; - expect(jobs).toMatchObject(expectedJob); + expect(result.total).toBe(1); + expect(result.items).toMatchObject(expectedJob); }); it('should return all formatted jobs when no criteria is provided', async function () { const jobEntity = { ...jobEntityWithoutStages }; prisma.job.findMany.mockResolvedValue([jobEntity]); + prisma.job.count.mockResolvedValue(1); - const jobs = await jobManager.getJobs(undefined); + const result = await jobManager.getJobs(undefined); const { xstate, stage, tracestate, ...rest } = jobEntity; const expectedJob = [{ ...rest, stages: stage, creationTime: rest.creationTime.toISOString(), updateTime: rest.updateTime.toISOString() }]; - expect(jobs).toMatchObject(expectedJob); + expect(result.total).toBe(1); + expect(result.items).toMatchObject(expectedJob); }); }); diff --git a/tests/unit/stages/stages.spec.ts b/tests/unit/stages/stages.spec.ts index 0f05771d..24a40000 100644 --- a/tests/unit/stages/stages.spec.ts +++ b/tests/unit/stages/stages.spec.ts @@ -64,14 +64,16 @@ describe('JobManager', () => { it('should return array with single stage formatted object by criteria without tasks', async function () { const stageEntity = createStageEntity({ type: 'SOME_STAGE_TYPE' }); prisma.stage.findMany.mockResolvedValue([stageEntity]); + prisma.stage.count.mockResolvedValue(1); - const stages = await stageManager.getStages({ stage_type: 'SOME_STAGE_TYPE' }); + const result = await stageManager.getStages({ stage_type: 'SOME_STAGE_TYPE' }); const { xstate, task, tracestate, ...rest } = stageEntity; const expectedStage = [rest]; - expect(stages).toMatchObject(expectedStage); - expect(stages[0]?.tasks).toBeUndefined(); + expect(result.total).toBe(1); + expect(result.items).toMatchObject(expectedStage); + expect(result.items[0]?.tasks).toBeUndefined(); }); it('should return array with single stage formatted object by criteria with related tasks', async function () { @@ -79,26 +81,30 @@ describe('JobManager', () => { const taskEntity = createTaskEntity({ stageId }); const stageEntity = createStageEntity({ id: stageId, task: [taskEntity], type: 'SOME_STAGE_TYPE' }); prisma.stage.findMany.mockResolvedValue([stageEntity]); + prisma.stage.count.mockResolvedValue(1); - const stages = await stageManager.getStages({ stage_type: 'SOME_STAGE_TYPE', should_return_tasks: true }); + const result = await stageManager.getStages({ stage_type: 'SOME_STAGE_TYPE', should_return_tasks: true }); const { xstate, task, tracestate, ...rest } = stageEntity; const expectedStage = [rest]; - expect(stages).toMatchObject(expectedStage); - expect(stages[0]?.tasks).toMatchObject([{ id: taskEntity.id }]); + expect(result.total).toBe(1); + expect(result.items).toMatchObject(expectedStage); + expect(result.items[0]?.tasks).toMatchObject([{ id: taskEntity.id }]); }); it('should return array with all stages when no criteria is provided', async function () { const stageEntity = createStageEntity({}); prisma.stage.findMany.mockResolvedValue([stageEntity]); + prisma.stage.count.mockResolvedValue(1); - const stages = await stageManager.getStages(undefined); + const result = await stageManager.getStages(undefined); const { xstate, task, tracestate, ...rest } = stageEntity; const expectedStage = [rest]; - expect(stages).toMatchObject(expectedStage); + expect(result.total).toBe(1); + expect(result.items).toMatchObject(expectedStage); }); }); @@ -188,7 +194,7 @@ describe('JobManager', () => { prisma.job.findUnique.mockResolvedValue(jobEntityWithStages); prisma.stage.findMany.mockResolvedValue([stageEntity]); - const stage = await stageManager.getStagesByJobId(stageEntity.jobId); + const stage = await stageManager.getStagesByJobId(stageEntity.jobId, true); const { xstate, task, tracestate, ...rest } = stageEntity; diff --git a/tests/unit/tasks/tasks.spec.ts b/tests/unit/tasks/tasks.spec.ts index 3b95e7d6..9f707d2d 100644 --- a/tests/unit/tasks/tasks.spec.ts +++ b/tests/unit/tasks/tasks.spec.ts @@ -80,33 +80,50 @@ describe('JobManager', () => { it('should return array with single task formatted object by criteria', async function () { const taskEntity = createTaskEntity({}); prisma.task.findMany.mockResolvedValue([taskEntity]); + prisma.task.count.mockResolvedValue(1); - const tasks = await taskManager.getTasks({ stage_type: 'SOME_STAGE_TYPE' }); + const result = await taskManager.getTasks({ stage_type: 'SOME_STAGE_TYPE' }); const { creationTime, updateTime, xstate, startTime, endTime, ...rest } = taskEntity; const expectedTask = [{ ...rest, tracestate: undefined, creationTime: creationTime.toISOString(), updateTime: updateTime.toISOString() }]; - expect(tasks).toMatchObject(expectedTask); + expect(result.total).toBe(1); + expect(result.items).toMatchObject(expectedTask); }); it('should return array with task formatted object by empty criteria', async function () { const taskEntity = createTaskEntity({}); prisma.task.findMany.mockResolvedValue([taskEntity]); + prisma.task.count.mockResolvedValue(1); - const tasks = await taskManager.getTasks({}); + const result = await taskManager.getTasks({}); const { creationTime, updateTime, xstate, startTime, endTime, ...rest } = taskEntity; const expectedTask = [{ ...rest, tracestate: undefined, creationTime: creationTime.toISOString(), updateTime: updateTime.toISOString() }]; - expect(tasks).toMatchObject(expectedTask); + expect(result.total).toBe(1); + expect(result.items).toMatchObject(expectedTask); }); it('should return empty array', async function () { prisma.task.findMany.mockResolvedValue([]); + prisma.task.count.mockResolvedValue(0); - const tasks = await taskManager.getTasks({ stage_type: 'SOME_STAGE_TYPE' }); + const result = await taskManager.getTasks({ stage_type: 'SOME_STAGE_TYPE' }); - expect(tasks).toMatchObject([]); + expect(result.total).toBe(0); + expect(result.items).toMatchObject([]); + }); + + it('should return all tasks when no criteria is provided', async function () { + const taskEntity = createTaskEntity({}); + prisma.task.findMany.mockResolvedValue([taskEntity]); + prisma.task.count.mockResolvedValue(1); + + const result = await taskManager.getTasks(undefined); + + expect(result.total).toBe(1); + expect(result.items).toHaveLength(1); }); }); @@ -157,16 +174,17 @@ describe('JobManager', () => { it('should return task object by provided stage id', async function () { const stageEntity = createStageEntity({}); const taskEntity = createTaskEntity({ stageId: stageEntity.id }); - prisma.stage.findUnique.mockResolvedValue(stageEntity); prisma.task.findMany.mockResolvedValue([taskEntity]); + prisma.task.count.mockResolvedValue(1); - const tasks = await taskManager.getTasksByStageId(stageEntity.id); + const result = await taskManager.getTasksByStageId(stageEntity.id, {}); const { creationTime, updateTime, xstate, startTime, endTime, ...rest } = taskEntity; const expectedTask = [{ ...rest, tracestate: undefined, creationTime: creationTime.toISOString(), updateTime: updateTime.toISOString() }]; - expect(tasks).toMatchObject(expectedTask); + expect(result.total).toBe(1); + expect(result.items).toMatchObject(expectedTask); }); }); @@ -174,7 +192,7 @@ describe('JobManager', () => { it('should failed on not founded task when getting by non exists stage', async function () { prisma.stage.findUnique.mockResolvedValue(null); - await expect(taskManager.getTasksByStageId('some_id')).rejects.toThrow(stagesErrorMessages.stageNotFound); + await expect(taskManager.getTasksByStageId('some_id', {})).rejects.toThrow(stagesErrorMessages.stageNotFound); }); }); @@ -182,7 +200,7 @@ describe('JobManager', () => { it('should fail and throw an error if prisma throws an error', async function () { prisma.stage.findUnique.mockRejectedValueOnce(new Error('db connection error')); - await expect(taskManager.getTasksByStageId('some_id')).rejects.toThrow('db connection error'); + await expect(taskManager.getTasksByStageId('some_id', {})).rejects.toThrow('db connection error'); }); }); }); diff --git a/vitest.config.mts b/vitest.config.mts index 1ecd89fc..2bc34b15 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -45,6 +45,7 @@ export default defineConfig({ setupFiles: ['./tests/configurations/initJestOpenapi.setup.ts', './tests/configurations/vite.setup.ts'], include: ['tests/integration/**/*.spec.ts'], environment: 'node', + fileParallelism: false, server: { deps: { external: ['node-cron', /prisma[\/]generated[\/]client[\/]runtime[\/]library/],