@@ -84,7 +84,9 @@ function kindOf(value: object): string {
8484}
8585
8686const DAG_SPEC_KEYS : ReadonlySet < string > = new Set ( Object . keys ( DAG_SCHEMA_FIELDS ) ) ;
87- const TASK_SPEC_KEYS : ReadonlySet < string > = new Set ( Object . keys ( TASK_SCHEMA_FIELDS ) ) ;
87+ // `taskId` is hand-written rather than generated: the schema's task_id is
88+ // serializer-owned, and this is the authoring surface's own way to set it.
89+ const TASK_SPEC_KEYS : ReadonlySet < string > = new Set ( [ ...Object . keys ( TASK_SCHEMA_FIELDS ) , "taskId" ] ) ;
8890
8991/**
9092 * Dag-level options: the schedule, the tags, how many runs may be active, and
@@ -103,9 +105,19 @@ export type DagSpec = GeneratedDagFields;
103105 * Task-level options: the retries, the pool, the trigger rule, and the rest of
104106 * what an operator takes in Python.
105107 *
108+ * The task id is here too, for a handler whose id is not given positionally.
106109 * Optional and record-only on the same terms as {@link DagSpec}.
107110 */
108- export type TaskSpec = GeneratedTaskFields ;
111+ export interface TaskSpec extends GeneratedTaskFields {
112+ /**
113+ * Airflow task ID, when it should not be the handler's function name.
114+ *
115+ * `dag.task(handler)` takes the id from the handler's name. Set this for an
116+ * id that has to outlive that name, or for an anonymous handler, which has
117+ * no name to take one from.
118+ */
119+ readonly taskId ?: string ;
120+ }
109121
110122// Carries a reference's return type without carrying a value. Not exported, so
111123// the property cannot be read or written from outside; it exists only so
@@ -259,10 +271,11 @@ export type TaskOptions = TaskSpec & {
259271 /**
260272 * Names for the handler's positional arguments, in declaration order.
261273 *
262- * `airflow-ts-pack` fills this in from the handler's parameter list, so the
263- * Dag names each argument as its handler does. Positional inputs bind by
264- * order, so a name left out only costs the label: `arg0`, `arg1` and so on
265- * stand in for it.
274+ * Read from the handler itself when it declares plain parameters, so the Dag
275+ * names each argument as its handler does. Set this to name them yourself,
276+ * or for a handler whose parameters are destructured, defaulted or rest.
277+ * Positional inputs bind by order, so a name left out only costs the label:
278+ * `arg0`, `arg1` and so on stand in for it.
266279 */
267280 readonly argBindings ?: readonly string [ ] ;
268281} ;
@@ -354,12 +367,70 @@ export class Dag {
354367 * {@link TaskFactory}; `getContext()` and `getClient()` reach the runtime
355368 * from inside the call, so neither is an argument. The trailing options object
356369 * carries this task's own {@link TaskSpec}.
370+ *
371+ * ```ts
372+ * const extract = dag.task("extract", async () => 42);
373+ * const transform = dag.task(async function transform() {}); // id "transform"
374+ * ```
357375 */
358376 task < TParams extends readonly unknown [ ] = [ ] , TReturn = unknown > (
359377 taskId : string ,
360378 handler : ( ...args : TParams ) => TReturn | Promise < TReturn > ,
361- options : TaskOptions = { } ,
379+ options ?: TaskOptions ,
380+ ) : TaskFactory < TParams , TReturn > ;
381+ /**
382+ * Declare a task whose id is the handler's function name.
383+ *
384+ * `airflow-ts-pack` keeps handler names intact, so minification cannot change
385+ * a task id. An anonymous handler has no name to take one from, and needs
386+ * {@link TaskSpec.taskId} to give it one.
387+ */
388+ task < TParams extends readonly unknown [ ] = [ ] , TReturn = unknown > (
389+ handler : ( ...args : TParams ) => TReturn | Promise < TReturn > ,
390+ options ?: TaskOptions ,
391+ ) : TaskFactory < TParams , TReturn > ;
392+ task < TParams extends readonly unknown [ ] = [ ] , TReturn = unknown > (
393+ taskIdOrHandler : string | ( ( ...args : TParams ) => TReturn | Promise < TReturn > ) ,
394+ handlerOrOptions ?: ( ( ...args : TParams ) => TReturn | Promise < TReturn > ) | TaskOptions ,
395+ maybeOptions ?: TaskOptions ,
362396 ) : TaskFactory < TParams , TReturn > {
397+ const idGiven = typeof taskIdOrHandler === "string" ;
398+ const handler = ( idGiven ? handlerOrOptions : taskIdOrHandler ) as (
399+ ...args : TParams
400+ ) => TReturn | Promise < TReturn > ;
401+ const given = idGiven ? maybeOptions : ( handlerOrOptions as TaskOptions | undefined ) ;
402+ // Defaulted only when absent: an explicit `null` is a bad spec, not an
403+ // omitted one, and #taskSpecOf is what reports it.
404+ const options = given === undefined ? { } : given ;
405+ const specTaskId =
406+ isPlainRecord ( options ) && typeof options . taskId === "string" ? options . taskId : undefined ;
407+ // Two ids for one task disagree silently otherwise: the positional one
408+ // wins and the spec's is dropped without a word.
409+ if ( idGiven && specTaskId !== undefined ) {
410+ throw new Error (
411+ `Task "${ taskIdOrHandler } " of Dag "${ this . dagId } " also carries taskId "${ specTaskId } " in ` +
412+ "its spec; give the id once, either positionally or in the spec" ,
413+ ) ;
414+ }
415+ const defaulted = idGiven ? undefined : ( specTaskId ?? readFunctionName ( handler ) ) ;
416+ const taskId = idGiven ? taskIdOrHandler : defaulted ;
417+ if ( taskId === undefined ) {
418+ throw new Error (
419+ `A task of Dag "${ this . dagId } " has no id: its handler has no name to take one from. ` +
420+ 'Pass an id — dag.task("my_task", handler) — or give the handler a name. A bundler ' +
421+ "that drops function names also lands here; airflow-ts-pack keeps them." ,
422+ ) ;
423+ }
424+ // A name Airflow would reject is worth catching where it was taken, not in
425+ // the server's answer: `fn.bind(...)` names itself "bound extract", and a
426+ // method can be named anything at all.
427+ if ( defaulted !== undefined && ! TASK_ID_CHARACTERS . test ( defaulted ) ) {
428+ throw new Error (
429+ `A task of Dag "${ this . dagId } " would take the id "${ defaulted } " from its handler's name, ` +
430+ "which Airflow does not accept; give it an id of letters, digits, dashes, dots and " +
431+ 'underscores — dag.task("my_task", handler)' ,
432+ ) ;
433+ }
363434 if ( typeof handler !== "function" ) {
364435 throw new Error ( `handler for Dag "${ this . dagId } " task "${ taskId } " must be a function` ) ;
365436 }
@@ -375,7 +446,8 @@ export class Dag {
375446 ) ;
376447 }
377448 const spec = this . #taskSpecOf( taskId , options ) ;
378- const argBindings = this . #validateArgBindings( taskId , options . argBindings ) ;
449+ const argBindings =
450+ this . #validateArgBindings( taskId , options . argBindings ) ?? inferArgBindings ( handler ) ;
379451 const task = createTaskRef ( this . dagId , taskId ) ;
380452 this . #tasks. set ( taskId , {
381453 task,
@@ -560,6 +632,54 @@ function spreadArgs(fn: TaskFunction): TaskFunction {
560632 handler ( ...Object . values ( args ?? { } ) ) ) as unknown as TaskFunction ;
561633}
562634
635+ // What Airflow accepts as a task id, and so what a name taken from a handler
636+ // has to look like.
637+ const TASK_ID_CHARACTERS = / ^ [ \p{ L} \p{ N} _ . - ] + $ / u;
638+
639+ // A parameter list of plain identifiers, which is the one shape whose names can
640+ // be read off a function without parsing it.
641+ const PLAIN_PARAMETERS = / ^ [ A - Z a - z _ $ ] [ \w $ ] * (?: \s * , \s * [ A - Z a - z _ $ ] [ \w $ ] * ) * $ / ;
642+
643+ /**
644+ * A handler's own function name, or undefined when it has none.
645+ *
646+ * Where a defaulted task id comes from. `airflow-ts-pack` bundles with esbuild's
647+ * `keepNames`, so the name survives minification; a bundler that drops names
648+ * leaves the empty string, which is why the empty string is not an id.
649+ */
650+ function readFunctionName ( handler : unknown ) : string | undefined {
651+ if ( typeof handler !== "function" ) return undefined ;
652+ const { name } = handler as { name ?: unknown } ;
653+ return typeof name === "string" && name . length > 0 ? name : undefined ;
654+ }
655+
656+ /**
657+ * The handler's parameter names, when it declares plain ones.
658+ *
659+ * All-or-nothing: a default, a destructuring pattern, a rest element or a
660+ * comment leaves the labels `arg0`, `arg1`, which bind exactly the same way.
661+ * `airflow-ts-pack` leaves identifiers alone, so these are the names the author
662+ * wrote even in a packed bundle.
663+ */
664+ function inferArgBindings ( handler : unknown ) : readonly string [ ] | undefined {
665+ if ( typeof handler !== "function" ) return undefined ;
666+ const source = Function . prototype . toString . call ( handler ) ;
667+ // `async rows => ...`: one parameter, and no parentheses to slice.
668+ const bare = / ^ (?: a s y n c \s + ) ? ( [ A - Z a - z _ $ ] [ \w $ ] * ) \s * = > / . exec ( source ) ;
669+ if ( bare ) return Object . freeze ( [ bare [ 1 ] ! ] ) ;
670+ const open = source . indexOf ( "(" ) ;
671+ const close = source . indexOf ( ")" ) ;
672+ const arrow = source . indexOf ( "=>" ) ;
673+ // A "(" past the arrow belongs to the body, not to a parameter list.
674+ if ( open === - 1 || close < open || ( arrow !== - 1 && open > arrow ) ) return undefined ;
675+ const parameters = source . slice ( open + 1 , close ) . trim ( ) ;
676+ if ( parameters === "" || ! PLAIN_PARAMETERS . test ( parameters ) ) return undefined ;
677+ const names = parameters . split ( "," ) . map ( ( name ) => name . trim ( ) ) ;
678+ // `length` stops at the first default or rest element, so it matches only a
679+ // plain list — and not a slice that a ")" inside a default value cut short.
680+ return names . length === handler . length ? Object . freeze ( names ) : undefined ;
681+ }
682+
563683function createTaskRef ( dagId : string , taskId : string ) : TaskRef {
564684 const task : TaskRef = { dagId, taskId } ;
565685 brand ( task , "TaskRef" ) ;
0 commit comments