@output.ai/core 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,8 @@ import {
3
3
  EvaluationResult,
4
4
  EvaluationStringResult,
5
5
  EvaluationNumberResult,
6
- EvaluationBooleanResult
6
+ EvaluationBooleanResult,
7
+ EvaluationFeedback
7
8
  } from './evaluator.js';
8
9
  import { ValidationError } from '#errors';
9
10
 
@@ -98,6 +99,467 @@ describe( 'interface/evaluator - EvaluationResult classes', () => {
98
99
  const bad = EvaluationBooleanResult.schema.safeParse( { value: 'false', confidence: 1 } );
99
100
  expect( bad.success ).toBe( false );
100
101
  } );
102
+
103
+ it( 'schema getter does not cause infinite recursion', () => {
104
+ // Access schema multiple times to ensure no stack overflow
105
+ const schema1 = EvaluationResult.schema;
106
+ const schema2 = EvaluationResult.schema;
107
+ const schema3 = EvaluationStringResult.schema;
108
+ const schema4 = EvaluationNumberResult.schema;
109
+ const schema5 = EvaluationBooleanResult.schema;
110
+
111
+ expect( schema1 ).toBeDefined();
112
+ expect( schema2 ).toBeDefined();
113
+ expect( schema3 ).toBeDefined();
114
+ expect( schema4 ).toBeDefined();
115
+ expect( schema5 ).toBeDefined();
116
+
117
+ // Verify schemas can be used for validation
118
+ const result = schema1.safeParse( {
119
+ value: 'test',
120
+ confidence: 0.8,
121
+ dimensions: [
122
+ { value: 'dim1', confidence: 0.9 },
123
+ { value: 42, confidence: 0.8 }
124
+ ]
125
+ } );
126
+ expect( result.success ).toBe( true );
127
+ } );
128
+
129
+ it( 'schema includes dimensions field', () => {
130
+ const schema = EvaluationResult.schema;
131
+ const result = schema.safeParse( {
132
+ value: 'test',
133
+ confidence: 0.8,
134
+ dimensions: [
135
+ { value: 'string-dim', confidence: 0.9 },
136
+ { value: 42, confidence: 0.8 },
137
+ { value: true, confidence: 0.7 }
138
+ ]
139
+ } );
140
+ expect( result.success ).toBe( true );
141
+ if ( result.success ) {
142
+ expect( result.data.dimensions ).toHaveLength( 3 );
143
+ }
144
+ } );
145
+
146
+ it( 'schema validates dimensions value types', () => {
147
+ const schema = EvaluationResult.schema;
148
+
149
+ // Valid: primitive value types
150
+ const valid = schema.safeParse( {
151
+ value: 'test',
152
+ confidence: 0.8,
153
+ dimensions: [
154
+ { value: 'string', confidence: 0.9 },
155
+ { value: 42, confidence: 0.8 },
156
+ { value: true, confidence: 0.7 }
157
+ ]
158
+ } );
159
+ expect( valid.success ).toBe( true );
160
+
161
+ // Invalid: non-primitive value type in dimensions
162
+ const invalid = schema.safeParse( {
163
+ value: 'test',
164
+ confidence: 0.8,
165
+ dimensions: [
166
+ { value: { object: 'not allowed' }, confidence: 0.9 }
167
+ ]
168
+ } );
169
+ expect( invalid.success ).toBe( false );
170
+ } );
171
+ } );
172
+
173
+ describe( 'new fields: name, dimensions, feedback', () => {
174
+ it( 'accepts optional name field', () => {
175
+ const result = new EvaluationResult( {
176
+ value: 'test',
177
+ confidence: 0.8,
178
+ name: 'test-evaluation'
179
+ } );
180
+ expect( result.name ).toBe( 'test-evaluation' );
181
+ } );
182
+
183
+ it( 'name defaults to undefined when not provided', () => {
184
+ const result = new EvaluationResult( {
185
+ value: 'test',
186
+ confidence: 0.8
187
+ } );
188
+ expect( result.name ).toBeUndefined();
189
+ } );
190
+
191
+ it( 'validates name must be string if provided', () => {
192
+ expect( () => new EvaluationResult( {
193
+ value: 'test',
194
+ confidence: 0.8,
195
+ name: 123
196
+ } ) ).toThrow( ValidationError );
197
+ } );
198
+
199
+ it( 'accepts dimensions array with EvaluationResult instances', () => {
200
+ const dim1 = new EvaluationStringResult( { value: 'dim1', confidence: 0.9 } );
201
+ const dim2 = new EvaluationNumberResult( { value: 42, confidence: 0.8 } );
202
+ const result = new EvaluationResult( {
203
+ value: 'main',
204
+ confidence: 0.7,
205
+ dimensions: [ dim1, dim2 ]
206
+ } );
207
+ expect( result.dimensions ).toHaveLength( 2 );
208
+ expect( result.dimensions[0] ).toBe( dim1 );
209
+ expect( result.dimensions[1] ).toBe( dim2 );
210
+ } );
211
+
212
+ it( 'dimensions defaults to empty array when not provided', () => {
213
+ const result = new EvaluationResult( {
214
+ value: 'test',
215
+ confidence: 0.8
216
+ } );
217
+ expect( result.dimensions ).toEqual( [] );
218
+ } );
219
+
220
+ it( 'validates dimensions must match subclass schema', () => {
221
+ expect( () => new EvaluationResult( {
222
+ value: 'test',
223
+ confidence: 0.8,
224
+ dimensions: [ { invalid: 'object' } ]
225
+ } ) ).toThrow( ValidationError );
226
+ } );
227
+
228
+ it( 'validates dimensions array structure', () => {
229
+ expect( () => new EvaluationResult( {
230
+ value: 'test',
231
+ confidence: 0.8,
232
+ dimensions: [
233
+ { value: 'valid', confidence: 0.9 },
234
+ { invalid: 'missing confidence' }
235
+ ]
236
+ } ) ).toThrow( ValidationError );
237
+ } );
238
+
239
+ it( 'accepts dimensions with all three subclass types', () => {
240
+ const result = new EvaluationResult( {
241
+ value: 'main',
242
+ confidence: 0.7,
243
+ dimensions: [
244
+ new EvaluationStringResult( { value: 'string-dim', confidence: 0.9 } ),
245
+ new EvaluationNumberResult( { value: 42, confidence: 0.8 } ),
246
+ new EvaluationBooleanResult( { value: true, confidence: 0.7 } )
247
+ ]
248
+ } );
249
+ expect( result.dimensions ).toHaveLength( 3 );
250
+ expect( result.dimensions[0] ).toBeInstanceOf( EvaluationStringResult );
251
+ expect( result.dimensions[1] ).toBeInstanceOf( EvaluationNumberResult );
252
+ expect( result.dimensions[2] ).toBeInstanceOf( EvaluationBooleanResult );
253
+ } );
254
+
255
+ it( 'accepts plain objects matching subclass schemas in dimensions', () => {
256
+ const result = new EvaluationResult( {
257
+ value: 'main',
258
+ confidence: 0.7,
259
+ dimensions: [
260
+ { value: 'string-dim', confidence: 0.9 },
261
+ { value: 42, confidence: 0.8 },
262
+ { value: true, confidence: 0.7 }
263
+ ]
264
+ } );
265
+ expect( result.dimensions ).toHaveLength( 3 );
266
+ expect( result.dimensions[0].value ).toBe( 'string-dim' );
267
+ expect( result.dimensions[1].value ).toBe( 42 );
268
+ expect( result.dimensions[2].value ).toBe( true );
269
+ } );
270
+
271
+ it( 'accepts string dimension value type', () => {
272
+ const result = new EvaluationResult( {
273
+ value: 'test',
274
+ confidence: 0.8,
275
+ dimensions: [
276
+ { value: 'string-value', confidence: 0.9 }
277
+ ]
278
+ } );
279
+ expect( result.dimensions[0].value ).toBe( 'string-value' );
280
+ } );
281
+
282
+ it( 'accepts number dimension value type', () => {
283
+ const result = new EvaluationResult( {
284
+ value: 'test',
285
+ confidence: 0.8,
286
+ dimensions: [
287
+ { value: 123, confidence: 0.9 }
288
+ ]
289
+ } );
290
+ expect( result.dimensions[0].value ).toBe( 123 );
291
+ } );
292
+
293
+ it( 'accepts boolean dimension value type', () => {
294
+ const result = new EvaluationResult( {
295
+ value: 'test',
296
+ confidence: 0.8,
297
+ dimensions: [
298
+ { value: true, confidence: 0.9 }
299
+ ]
300
+ } );
301
+ expect( result.dimensions[0].value ).toBe( true );
302
+ } );
303
+
304
+ it( 'rejects dimensions with non-primitive value types', () => {
305
+ expect( () => new EvaluationResult( {
306
+ value: 'test',
307
+ confidence: 0.8,
308
+ dimensions: [
309
+ { value: { object: 'not allowed' }, confidence: 0.9 }
310
+ ]
311
+ } ) ).toThrow( ValidationError );
312
+ } );
313
+
314
+ it( 'rejects dimensions with array value types', () => {
315
+ expect( () => new EvaluationResult( {
316
+ value: 'test',
317
+ confidence: 0.8,
318
+ dimensions: [
319
+ { value: [ 1, 2, 3 ], confidence: 0.9 }
320
+ ]
321
+ } ) ).toThrow( ValidationError );
322
+ } );
323
+
324
+ it( 'accepts nested dimensions (recursive)', () => {
325
+ const nestedDim = new EvaluationStringResult( {
326
+ value: 'nested',
327
+ confidence: 0.9,
328
+ dimensions: [
329
+ new EvaluationNumberResult( { value: 10, confidence: 0.8 } )
330
+ ]
331
+ } );
332
+ const result = new EvaluationResult( {
333
+ value: 'main',
334
+ confidence: 0.7,
335
+ dimensions: [ nestedDim ]
336
+ } );
337
+ expect( result.dimensions ).toHaveLength( 1 );
338
+ expect( result.dimensions[0].dimensions ).toHaveLength( 1 );
339
+ expect( result.dimensions[0].dimensions[0].value ).toBe( 10 );
340
+ } );
341
+
342
+ it( 'accepts nested dimensions with plain objects', () => {
343
+ const result = new EvaluationResult( {
344
+ value: 'main',
345
+ confidence: 0.7,
346
+ dimensions: [
347
+ {
348
+ value: 'nested',
349
+ confidence: 0.9,
350
+ dimensions: [
351
+ { value: 10, confidence: 0.8 }
352
+ ]
353
+ }
354
+ ]
355
+ } );
356
+ expect( result.dimensions ).toHaveLength( 1 );
357
+ expect( result.dimensions[0].dimensions ).toHaveLength( 1 );
358
+ expect( result.dimensions[0].dimensions[0].value ).toBe( 10 );
359
+ } );
360
+
361
+ it( 'accepts feedback array with EvaluationFeedback instances', () => {
362
+ const feedback1 = new EvaluationFeedback( {
363
+ issue: 'Issue 1',
364
+ suggestion: 'Fix this',
365
+ priority: 'high'
366
+ } );
367
+ const feedback2 = new EvaluationFeedback( {
368
+ issue: 'Issue 2',
369
+ reference: 'ref-123'
370
+ } );
371
+ const result = new EvaluationResult( {
372
+ value: 'test',
373
+ confidence: 0.8,
374
+ feedback: [ feedback1, feedback2 ]
375
+ } );
376
+ expect( result.feedback ).toHaveLength( 2 );
377
+ expect( result.feedback[0] ).toBe( feedback1 );
378
+ expect( result.feedback[1] ).toBe( feedback2 );
379
+ } );
380
+
381
+ it( 'accepts feedback array with plain objects matching schema', () => {
382
+ const result = new EvaluationResult( {
383
+ value: 'test',
384
+ confidence: 0.8,
385
+ feedback: [
386
+ { issue: 'Issue 1', suggestion: 'Fix', priority: 'high' },
387
+ { issue: 'Issue 2', reference: 'ref-123' }
388
+ ]
389
+ } );
390
+ expect( result.feedback ).toHaveLength( 2 );
391
+ expect( result.feedback[0].issue ).toBe( 'Issue 1' );
392
+ expect( result.feedback[1].issue ).toBe( 'Issue 2' );
393
+ } );
394
+
395
+ it( 'feedback defaults to empty array when not provided', () => {
396
+ const result = new EvaluationResult( {
397
+ value: 'test',
398
+ confidence: 0.8
399
+ } );
400
+ expect( result.feedback ).toEqual( [] );
401
+ } );
402
+
403
+ it( 'validates feedback must match EvaluationFeedback schema', () => {
404
+ expect( () => new EvaluationResult( {
405
+ value: 'test',
406
+ confidence: 0.8,
407
+ feedback: [ { invalid: 'object' } ]
408
+ } ) ).toThrow( ValidationError );
409
+ } );
410
+
411
+ it( 'validates feedback issue is required', () => {
412
+ expect( () => new EvaluationResult( {
413
+ value: 'test',
414
+ confidence: 0.8,
415
+ feedback: [ { suggestion: 'missing issue' } ]
416
+ } ) ).toThrow( ValidationError );
417
+ } );
418
+
419
+ it( 'validates feedback priority enum values', () => {
420
+ expect( () => new EvaluationResult( {
421
+ value: 'test',
422
+ confidence: 0.8,
423
+ feedback: [ { issue: 'test', priority: 'invalid' } ]
424
+ } ) ).toThrow( ValidationError );
425
+ } );
426
+
427
+ it( 'accepts all new fields together', () => {
428
+ const dim = new EvaluationStringResult( { value: 'dim', confidence: 0.9 } );
429
+ const feedback = new EvaluationFeedback( { issue: 'test issue', priority: 'medium' } );
430
+ const result = new EvaluationResult( {
431
+ value: 'main',
432
+ confidence: 0.8,
433
+ name: 'comprehensive-test',
434
+ dimensions: [ dim ],
435
+ feedback: [ feedback ],
436
+ reasoning: 'test reasoning'
437
+ } );
438
+ expect( result.name ).toBe( 'comprehensive-test' );
439
+ expect( result.dimensions ).toHaveLength( 1 );
440
+ expect( result.feedback ).toHaveLength( 1 );
441
+ expect( result.reasoning ).toBe( 'test reasoning' );
442
+ } );
443
+ } );
444
+ } );
445
+
446
+ describe( 'interface/evaluator - EvaluationFeedback class', () => {
447
+ describe( 'constructor', () => {
448
+ it( 'creates feedback with required issue', () => {
449
+ const feedback = new EvaluationFeedback( { issue: 'Test issue' } );
450
+ expect( feedback.issue ).toBe( 'Test issue' );
451
+ expect( feedback.suggestion ).toBeUndefined();
452
+ expect( feedback.reference ).toBeUndefined();
453
+ expect( feedback.priority ).toBeUndefined();
454
+ } );
455
+
456
+ it( 'creates feedback with all fields', () => {
457
+ const feedback = new EvaluationFeedback( {
458
+ issue: 'Critical bug',
459
+ suggestion: 'Fix immediately',
460
+ reference: 'BUG-123',
461
+ priority: 'critical'
462
+ } );
463
+ expect( feedback.issue ).toBe( 'Critical bug' );
464
+ expect( feedback.suggestion ).toBe( 'Fix immediately' );
465
+ expect( feedback.reference ).toBe( 'BUG-123' );
466
+ expect( feedback.priority ).toBe( 'critical' );
467
+ } );
468
+
469
+ it( 'accepts optional fields', () => {
470
+ const feedback = new EvaluationFeedback( {
471
+ issue: 'Minor issue',
472
+ suggestion: 'Consider fixing'
473
+ } );
474
+ expect( feedback.issue ).toBe( 'Minor issue' );
475
+ expect( feedback.suggestion ).toBe( 'Consider fixing' );
476
+ expect( feedback.reference ).toBeUndefined();
477
+ expect( feedback.priority ).toBeUndefined();
478
+ } );
479
+ } );
480
+
481
+ describe( 'static schema getter', () => {
482
+ it( 'validates required issue field', () => {
483
+ const ok = EvaluationFeedback.schema.safeParse( { issue: 'Test issue' } );
484
+ expect( ok.success ).toBe( true );
485
+
486
+ const bad = EvaluationFeedback.schema.safeParse( {} );
487
+ expect( bad.success ).toBe( false );
488
+ } );
489
+
490
+ it( 'validates issue must be string', () => {
491
+ const bad = EvaluationFeedback.schema.safeParse( { issue: 123 } );
492
+ expect( bad.success ).toBe( false );
493
+ } );
494
+
495
+ it( 'accepts optional suggestion field', () => {
496
+ const ok = EvaluationFeedback.schema.safeParse( {
497
+ issue: 'Test',
498
+ suggestion: 'Fix it'
499
+ } );
500
+ expect( ok.success ).toBe( true );
501
+ } );
502
+
503
+ it( 'validates suggestion must be string if provided', () => {
504
+ const bad = EvaluationFeedback.schema.safeParse( {
505
+ issue: 'Test',
506
+ suggestion: 123
507
+ } );
508
+ expect( bad.success ).toBe( false );
509
+ } );
510
+
511
+ it( 'accepts optional reference field', () => {
512
+ const ok = EvaluationFeedback.schema.safeParse( {
513
+ issue: 'Test',
514
+ reference: 'REF-123'
515
+ } );
516
+ expect( ok.success ).toBe( true );
517
+ } );
518
+
519
+ it( 'validates reference must be string if provided', () => {
520
+ const bad = EvaluationFeedback.schema.safeParse( {
521
+ issue: 'Test',
522
+ reference: 123
523
+ } );
524
+ expect( bad.success ).toBe( false );
525
+ } );
526
+
527
+ it( 'accepts valid priority enum values', () => {
528
+ const priorities = [ 'low', 'medium', 'high', 'critical' ];
529
+ for ( const priority of priorities ) {
530
+ const ok = EvaluationFeedback.schema.safeParse( {
531
+ issue: 'Test',
532
+ priority
533
+ } );
534
+ expect( ok.success ).toBe( true );
535
+ }
536
+ } );
537
+
538
+ it( 'rejects invalid priority values', () => {
539
+ const bad = EvaluationFeedback.schema.safeParse( {
540
+ issue: 'Test',
541
+ priority: 'invalid'
542
+ } );
543
+ expect( bad.success ).toBe( false );
544
+ } );
545
+
546
+ it( 'validates priority must be string if provided', () => {
547
+ const bad = EvaluationFeedback.schema.safeParse( {
548
+ issue: 'Test',
549
+ priority: 123
550
+ } );
551
+ expect( bad.success ).toBe( false );
552
+ } );
553
+
554
+ it( 'accepts all fields together', () => {
555
+ const ok = EvaluationFeedback.schema.safeParse( {
556
+ issue: 'Critical bug',
557
+ suggestion: 'Fix immediately',
558
+ reference: 'BUG-123',
559
+ priority: 'critical'
560
+ } );
561
+ expect( ok.success ).toBe( true );
562
+ } );
101
563
  } );
102
564
  } );
103
565
 
@@ -17,6 +17,12 @@ const refineSchema = ( value, ctx ) => {
17
17
  } );
18
18
  };
19
19
 
20
+ export const executeInParallelSchema = z.object( {
21
+ jobs: z.array( z.function() ),
22
+ concurrency: z.number().min( 1 ).or( z.literal( Infinity ) ),
23
+ onJobCompleted: z.function().optional()
24
+ } );
25
+
20
26
  export const durationSchema = z.union( [ z.string().regex(
21
27
  /^(\d+)(ms|s|m|h|d)$/,
22
28
  'Expected duration like "500ms", "10s", "5m", "2h", or "1d"'
@@ -108,3 +114,13 @@ export function validateWorkflow( args ) {
108
114
  export function validateRequestPayload( args ) {
109
115
  validateAgainstSchema( httpRequestSchema, args );
110
116
  };
117
+
118
+ /**
119
+ * Validate executeInParallel
120
+ *
121
+ * @param {object} args - The request arguments
122
+ * @throws {StaticValidationError} Throws if args are invalid
123
+ */
124
+ export function validateExecuteInParallel( args ) {
125
+ validateAgainstSchema( executeInParallelSchema, args );
126
+ };
@@ -1,6 +1,13 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { z } from 'zod';
3
- import { validateStep, validateWorkflow, validateRequestPayload, validateEvaluator, StaticValidationError } from './static.js';
3
+ import {
4
+ validateStep,
5
+ validateWorkflow,
6
+ validateRequestPayload,
7
+ validateEvaluator,
8
+ validateExecuteInParallel,
9
+ StaticValidationError
10
+ } from './static.js';
4
11
 
5
12
  const validArgs = Object.freeze( {
6
13
  name: 'valid_name',
@@ -259,4 +266,75 @@ describe( 'interface/validator', () => {
259
266
  expect( () => validateRequestPayload( request ) ).not.toThrow();
260
267
  } );
261
268
  } );
269
+
270
+ describe( 'validateExecuteInParallel', () => {
271
+ const validArgs = Object.freeze( {
272
+ jobs: [ () => {}, () => {} ],
273
+ concurrency: 5
274
+ } );
275
+
276
+ it( 'passes for valid args', () => {
277
+ expect( () => validateExecuteInParallel( { ...validArgs } ) ).not.toThrow();
278
+ } );
279
+
280
+ it( 'rejects missing concurrency', () => {
281
+ const error = new StaticValidationError( '✖ Invalid input\n → at concurrency' );
282
+ expect( () => validateExecuteInParallel( { jobs: validArgs.jobs } ) ).toThrow( error );
283
+ } );
284
+
285
+ it( 'passes with onJobCompleted callback', () => {
286
+ const args = {
287
+ ...validArgs,
288
+ onJobCompleted: () => {}
289
+ };
290
+ expect( () => validateExecuteInParallel( args ) ).not.toThrow();
291
+ } );
292
+
293
+ it( 'passes with concurrency 1', () => {
294
+ expect( () => validateExecuteInParallel( { ...validArgs, concurrency: 1 } ) ).not.toThrow();
295
+ } );
296
+
297
+ it( 'passes with concurrency Infinity', () => {
298
+ expect( () => validateExecuteInParallel( { ...validArgs, concurrency: Infinity } ) ).not.toThrow();
299
+ } );
300
+
301
+ it( 'rejects missing jobs', () => {
302
+ const error = new StaticValidationError( '✖ Invalid input: expected array, received undefined\n → at jobs' );
303
+ expect( () => validateExecuteInParallel( { concurrency: 5 } ) ).toThrow( error );
304
+ } );
305
+
306
+ it( 'rejects non-array jobs', () => {
307
+ const error = new StaticValidationError( '✖ Invalid input: expected array, received string\n → at jobs' );
308
+ expect( () => validateExecuteInParallel( { jobs: 'not-array', concurrency: 5 } ) ).toThrow( error );
309
+ } );
310
+
311
+ it( 'passes with empty jobs array', () => {
312
+ expect( () => validateExecuteInParallel( { jobs: [], concurrency: 5 } ) ).not.toThrow();
313
+ } );
314
+
315
+ it( 'rejects jobs array with non-function', () => {
316
+ const error = new StaticValidationError( '✖ Invalid input: expected function, received string\n → at jobs[1]' );
317
+ expect( () => validateExecuteInParallel( { jobs: [ () => {}, 'not-function' ], concurrency: 5 } ) ).toThrow( error );
318
+ } );
319
+
320
+ it( 'rejects non-number concurrency', () => {
321
+ const error = new StaticValidationError( '✖ Invalid input\n → at concurrency' );
322
+ expect( () => validateExecuteInParallel( { jobs: validArgs.jobs, concurrency: '5' } ) ).toThrow( error );
323
+ } );
324
+
325
+ it( 'rejects zero concurrency', () => {
326
+ const error = new StaticValidationError( '✖ Too small: expected number to be >=1\n → at concurrency' );
327
+ expect( () => validateExecuteInParallel( { jobs: validArgs.jobs, concurrency: 0 } ) ).toThrow( error );
328
+ } );
329
+
330
+ it( 'rejects negative concurrency', () => {
331
+ const error = new StaticValidationError( '✖ Too small: expected number to be >=1\n → at concurrency' );
332
+ expect( () => validateExecuteInParallel( { ...validArgs, concurrency: -1 } ) ).toThrow( error );
333
+ } );
334
+
335
+ it( 'rejects non-function onJobCompleted', () => {
336
+ const error = new StaticValidationError( '✖ Invalid input: expected function, received string\n → at onJobCompleted' );
337
+ expect( () => validateExecuteInParallel( { ...validArgs, onJobCompleted: 'not-function' } ) ).toThrow( error );
338
+ } );
339
+ } );
262
340
  } );
@@ -0,0 +1,49 @@
1
+ import { validateExecuteInParallel } from './validations/static.js';
2
+
3
+ /**
4
+ * Execute jobs in parallel with optional concurrency limit.
5
+ *
6
+ * Returns all job results (successes and failures) sorted by original job index.
7
+ *
8
+ * @param {Array<Function>} jobs Array of functions to execute
9
+ * @param {Number} [concurrency] Max concurrent jobs, default is Infinity (no concurrency limit)
10
+ * @param {Function} [onJobCompleted] Optional callback invoked as each job completes
11
+ */
12
+ export const executeInParallel = async ( { jobs, concurrency = Infinity, onJobCompleted } ) => {
13
+ validateExecuteInParallel( { jobs, concurrency, onJobCompleted } );
14
+ // allows this function to be called without testing over and over to check if it is not null;
15
+ const onJobCompletedSafeCb = onJobCompleted ?? ( _ => 0 );
16
+ const results = [];
17
+ const jobsCount = jobs.length;
18
+ const jobsPool = jobs.slice().map( ( job, index ) => ( {
19
+ index,
20
+ fn: async () => {
21
+ try {
22
+ const result = await job();
23
+ return { ok: true, result, index };
24
+ } catch ( error ) {
25
+ return { ok: false, error, index };
26
+ }
27
+ },
28
+ promise: null
29
+ } ) );
30
+
31
+ const activeJobs = jobsPool.splice( 0, concurrency );
32
+ activeJobs.forEach( job => job.promise = job.fn() ); // start jobs
33
+
34
+ while ( results.length < jobsCount ) {
35
+ const result = await Promise.race( activeJobs.map( job => job.promise ) );
36
+ results.push( result );
37
+ onJobCompletedSafeCb( result );
38
+
39
+ activeJobs.splice( activeJobs.findIndex( job => job.index === result.index ), 1 ); // remove completed job
40
+
41
+ if ( jobsPool.length > 0 ) {
42
+ const nextJob = jobsPool.shift();
43
+ nextJob.promise = nextJob.fn();
44
+ activeJobs.push( nextJob );
45
+ }
46
+ }
47
+
48
+ return results.sort( ( a, b ) => a.index - b.index );
49
+ };