-
Notifications
You must be signed in to change notification settings - Fork 44
feat(nodes): add loopNode - iterate over arrays with rate limiting #66
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { loopNode, LoopInputSchema } from '../loop'; | ||
|
|
||
| describe('loopNode', () => { | ||
| it('should have correct metadata', () => { | ||
| expect(loopNode.type).toBe('loop'); | ||
| expect(loopNode.category).toBe('logic'); | ||
| expect(loopNode.name).toBe('Loop'); | ||
| }); | ||
|
|
||
| it('should validate valid input', () => { | ||
| const result = LoopInputSchema.safeParse({ | ||
| items: [1, 2, 3], | ||
| }); | ||
| expect(result.success).toBe(true); | ||
| }); | ||
|
|
||
| it('should apply defaults', () => { | ||
| const result = LoopInputSchema.parse({ | ||
| items: [1, 2, 3], | ||
| }); | ||
| expect(result.concurrency).toBe(1); | ||
| expect(result.delayMs).toBe(0); | ||
| expect(result.continueOnError).toBe(false); | ||
| }); | ||
|
|
||
| it('should reject invalid concurrency', () => { | ||
| const result = LoopInputSchema.safeParse({ | ||
| items: [1], | ||
| concurrency: 0, | ||
| }); | ||
| expect(result.success).toBe(false); | ||
| }); | ||
|
|
||
| it('should reject negative delay', () => { | ||
| const result = LoopInputSchema.safeParse({ | ||
| items: [1], | ||
| delayMs: -1, | ||
| }); | ||
| expect(result.success).toBe(false); | ||
| }); | ||
|
|
||
| it('should handle empty array', async () => { | ||
| const mockContext = { | ||
| userId: 'test', | ||
| workflowExecutionId: 'test', | ||
| credentials: {}, | ||
| variables: {}, | ||
| interpolate: (s: string) => s, | ||
| evaluateJsonPath: (s: string) => s, | ||
| }; | ||
|
Comment on lines
+44
to
+51
|
||
|
|
||
| const result = await loopNode.executor( | ||
| { items: [], concurrency: 1, delayMs: 0, continueOnError: false }, | ||
| mockContext as any | ||
| ); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(result.output?.results).toEqual([]); | ||
| }); | ||
|
|
||
| it('should process items sequentially', async () => { | ||
| const mockContext = { | ||
| userId: 'test', | ||
| workflowExecutionId: 'test', | ||
| credentials: {}, | ||
| variables: {}, | ||
| interpolate: (s: string) => s, | ||
| evaluateJsonPath: (s: string) => s, | ||
| }; | ||
|
|
||
| const result = await loopNode.executor( | ||
| { items: ['a', 'b', 'c'], concurrency: 1, delayMs: 0, continueOnError: false }, | ||
| mockContext as any | ||
| ); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(result.output?.results).toEqual(['a', 'b', 'c']); | ||
| }); | ||
|
|
||
| it('should process items concurrently', async () => { | ||
| const mockContext = { | ||
| userId: 'test', | ||
| workflowExecutionId: 'test', | ||
| credentials: {}, | ||
| variables: {}, | ||
| interpolate: (s: string) => s, | ||
| evaluateJsonPath: (s: string) => s, | ||
| }; | ||
|
|
||
| const result = await loopNode.executor( | ||
| { items: [1, 2, 3, 4, 5], concurrency: 3, delayMs: 0, continueOnError: false }, | ||
| mockContext as any | ||
| ); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(result.output?.results).toEqual([1, 2, 3, 4, 5]); | ||
| }); | ||
|
Comment on lines
+62
to
+98
|
||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,192 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { z } from 'zod'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { defineNode } from '@jam-nodes/core'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Input schema for loop node | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const LoopInputSchema = z.object({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Array of items to iterate over */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| items: z.array(z.unknown()), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Max parallel executions (default: 1 for sequential) */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| concurrency: z.number().min(1).max(100).default(1), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Delay between iterations in milliseconds */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| delayMs: z.number().min(0).max(60000).default(0), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Continue processing remaining items if one fails */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continueOnError: z.boolean().default(false), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export type LoopInput = z.infer<typeof LoopInputSchema>; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Error entry for a failed iteration | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const LoopErrorSchema = z.object({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| index: z.number(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| error: z.string(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export type LoopError = z.infer<typeof LoopErrorSchema>; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Output schema for loop node | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const LoopOutputSchema = z.object({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| results: z.array(z.unknown()), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| errors: z.array(LoopErrorSchema).optional(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export type LoopOutput = z.infer<typeof LoopOutputSchema>; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Sleep utility for rate limiting between iterations | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function sleep(ms: number): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Process items sequentially with optional delay | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function processSequential( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| items: unknown[], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| delayMs: number, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continueOnError: boolean | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<{ results: unknown[]; errors: LoopError[] }> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const results: unknown[] = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const errors: LoopError[] = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (let i = 0; i < items.length; i++) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| results.push(items[i]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const errorMsg = err instanceof Error ? err.message : String(err); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| errors.push({ index: i, error: errorMsg }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!continueOnError) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| results.push(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+53
to
+68
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continueOnError: boolean | |
| ): Promise<{ results: unknown[]; errors: LoopError[] }> { | |
| const results: unknown[] = []; | |
| const errors: LoopError[] = []; | |
| for (let i = 0; i < items.length; i++) { | |
| try { | |
| results.push(items[i]); | |
| } catch (err) { | |
| const errorMsg = err instanceof Error ? err.message : String(err); | |
| errors.push({ index: i, error: errorMsg }); | |
| if (!continueOnError) { | |
| break; | |
| } | |
| results.push(null); | |
| } | |
| _continueOnError: boolean | |
| ): Promise<{ results: unknown[]; errors: LoopError[] }> { | |
| const results: unknown[] = []; | |
| const errors: LoopError[] = []; | |
| for (let i = 0; i < items.length; i++) { | |
| results.push(items[i]); |
Copilot
AI
Apr 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
processConcurrent assigns results[globalIndex] = item inside a try/catch, but that assignment (and reading item) will not throw in typical usage. As a result, errors will never populate and continueOnError/stopped won't have any real effect. If per-item execution is intended, introduce an actual per-item processing step that can fail and be captured; otherwise remove this error-handling logic to avoid giving a false sense of resilience.
| let stopped = false; | |
| // Process in batches of `concurrency` | |
| for (let batchStart = 0; batchStart < items.length; batchStart += concurrency) { | |
| if (stopped) break; | |
| const batchEnd = Math.min(batchStart + concurrency, items.length); | |
| const batch = items.slice(batchStart, batchEnd); | |
| const batchPromises = batch.map(async (item, batchIndex) => { | |
| const globalIndex = batchStart + batchIndex; | |
| try { | |
| results[globalIndex] = item; | |
| } catch (err) { | |
| const errorMsg = err instanceof Error ? err.message : String(err); | |
| errors.push({ index: globalIndex, error: errorMsg }); | |
| if (!continueOnError) { | |
| stopped = true; | |
| } | |
| } | |
| }); | |
| await Promise.all(batchPromises); | |
| if (delayMs > 0 && batchEnd < items.length && !stopped) { | |
| void continueOnError; | |
| // Process in batches of `concurrency` | |
| for (let batchStart = 0; batchStart < items.length; batchStart += concurrency) { | |
| const batchEnd = Math.min(batchStart + concurrency, items.length); | |
| const batch = items.slice(batchStart, batchEnd); | |
| const batchPromises = batch.map(async (item, batchIndex) => { | |
| const globalIndex = batchStart + batchIndex; | |
| results[globalIndex] = item; | |
| }); | |
| await Promise.all(batchPromises); | |
| if (delayMs > 0 && batchEnd < items.length) { |
Copilot
AI
Apr 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docstring/PR description says the loop node manages iteration while the execution engine runs child nodes for each item, but this implementation never invokes any child execution and simply echoes items back as results. This makes concurrency/rate limiting largely meaningless and doesn't meet the stated behavior of “executing child nodes for each item”. Either integrate with the runtime’s child-node execution mechanism (so results reflect per-item execution outputs) or update the node/PR description to reflect that this is currently a pass-through iterator.
Copilot
AI
Apr 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the empty-items fast path, errors is returned as an empty array even though the schema marks it as optional and the non-empty path omits it when there are no errors. For consistency (and to match the documented output shape), return errors: undefined (or omit the property) when there are no errors.
| errors: [], | |
| errors: undefined, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test imports should follow the project’s ESM convention of using
.jsextensions (seewebhook-trigger.test.ts).import { loopNode, LoopInputSchema } from '../loop';is inconsistent and can break under strict ESM resolution. Update to import from../loop.js.