This is an automated email from the ASF dual-hosted git repository.

vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new 75b8c05bb88 [v3-4-test] TS SDK: Add edge labels with label() (#74494) 
(#75031)
75b8c05bb88 is described below

commit 75b8c05bb8862a399944fbace7205096096b752a
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Oct 19 15:15:17 2026 +0530

    [v3-4-test] TS SDK: Add edge labels with label() (#74494) (#75031)
    
    * TS SDK: Add edge labels with label()
    
    * TS SDK: Tighten label() misuse checks
    
    - Throw when before() or after() is called on a label, instead of dropping 
it.
    - Validate label() arguments and reject labels in then/else/case with a 
hint.
    - Report a label from another SDK copy with the duplicate-copy hint.
    - Omit the label key on unlabelled edges.
    - Test a labelled group-as-upstream edge on an acyclic Dag.
    
    
    
    * Use US spelling of labeled in TS SDK docs
    
    * TS SDK: Reject misplaced and empty labels, reserve group join IDs
    
    A label() passed as a task input drew no edge and failed later at
    serialization, and an empty label replaced an earlier one on the edge.
    A task or group could also take a group's join node ID, under which
    edge labels at a group end are keyed. label is now in the API docs.
    
    
    
    ---------
    (cherry picked from commit 777bf9b18881f59a9fb2bfd55f033dbe8904d15c)
    
    Co-authored-by: Guan-Ming Chiu 
<[email protected]>
    Co-authored-by: ZHE YOU LIU <[email protected]>
    Co-authored-by: Claude <[email protected]>
---
 .../language-sdks/typescript.rst                   |  18 +++
 ts-sdk/api-docs/dag-authoring-api.ts               |   1 +
 ts-sdk/src/coordinator/serde.ts                    |  18 ++-
 ts-sdk/src/index.ts                                |   2 +-
 ts-sdk/src/sdk/dag.ts                              | 112 ++++++++++++++++--
 ts-sdk/tests/coordinator/serde.test.ts             |  19 +++
 ts-sdk/tests/public-api.test.ts                    |   2 +
 ts-sdk/tests/sdk/branching.test.ts                 |  10 ++
 ts-sdk/tests/sdk/conditional.test.ts               |  23 ++++
 ts-sdk/tests/sdk/dag.test.ts                       | 128 +++++++++++++++++++++
 10 files changed, 321 insertions(+), 12 deletions(-)

diff --git 
a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst 
b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
index 0cb37f578e2..84b40c60ca9 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
@@ -243,6 +243,24 @@ the reference it was called on, so 
``loaded.before(cleaned).before(notified)`` d
 Pass a value as an input when the downstream task needs it, and use ``before`` 
or ``after`` when it only needs
 to run in order.
 
+Wrap a reference in ``label`` to name the edge drawn to it, as Python's 
``Label`` does:
+
+.. code-block:: typescript
+
+    import { label } from "apache-airflow-ts-sdk";
+
+    checked.before(label(processed, "rows found"), label(notified, "no rows"));
+
+A labeled reference only goes into ``before`` or ``after``; calling either on 
it throws. Redrawing an
+existing edge, including one an input drew, labels it. That is also how to 
label the branches of ``dag.if``
+or ``dag.switch`` (see below):
+
+.. code-block:: typescript
+
+    const rowCheck = dag.if(hasRows, { total });
+    rowCheck.then(loaded).else(reportedEmpty);
+    rowCheck.before(label(loaded, "rows found"), label(reportedEmpty, "no 
rows"));
+
 Task groups
 ~~~~~~~~~~~
 
diff --git a/ts-sdk/api-docs/dag-authoring-api.ts 
b/ts-sdk/api-docs/dag-authoring-api.ts
index b37278f7f99..8fd6cc4427c 100644
--- a/ts-sdk/api-docs/dag-authoring-api.ts
+++ b/ts-sdk/api-docs/dag-authoring-api.ts
@@ -24,6 +24,7 @@ export {
   Dag,
   getClient,
   getContext,
+  label,
   NEVER_EXPIRE,
   TaskHandler,
   triggerDagRun,
diff --git a/ts-sdk/src/coordinator/serde.ts b/ts-sdk/src/coordinator/serde.ts
index 95da4e364af..c8bcd655009 100644
--- a/ts-sdk/src/coordinator/serde.ts
+++ b/ts-sdk/src/coordinator/serde.ts
@@ -41,10 +41,13 @@ import {
 } from "../generated/dag-schema-fields.js";
 import type { JsonValue } from "../sdk/client-types.js";
 import {
+  DOWNSTREAM_JOIN_SUFFIX,
+  getDagOrderEdges,
   getDagTaskGroups,
   getDagTaskInputs,
   getDagTaskRecords,
   isTaskRef,
+  UPSTREAM_JOIN_SUFFIX,
   type Dag,
   type RecordedInputs,
   type TaskGroupRecord,
@@ -84,6 +87,19 @@ const TASK_MODULE = "airflow.sdk.coordinators.node";
  */
 const TASK_LANGUAGE = "typescript";
 
+/** Python's `DAG.edge_info`, keying a group end by its join node as Python 
does. */
+function serializeEdgeInfo(dag: Dag): SerializedValue {
+  const groups = getDagTaskGroups(dag);
+  const info: Record<string, Record<string, SerializedValue>> = {};
+  for (const { upstream, downstream, label } of getDagOrderEdges(dag)) {
+    if (label === undefined) continue;
+    const from = groups.has(upstream) ? upstream + DOWNSTREAM_JOIN_SUFFIX : 
upstream;
+    const to = groups.has(downstream) ? downstream + UPSTREAM_JOIN_SUFFIX : 
downstream;
+    (info[from] ??= {})[to] = { label };
+  }
+  return info;
+}
+
 /** The Dags this one's tasks depend on, for the UI dependency graph. */
 function serializeDagDependencies(dag: Dag): SerializedValue {
   const dependencies: SerializedValue[] = [];
@@ -170,7 +186,7 @@ export function serializeDag(
     ),
     dag_dependencies: serializeDagDependencies(dag),
     task_group: serializeTaskGroups(dag, graph),
-    edge_info: {},
+    edge_info: serializeEdgeInfo(dag),
     params: [],
     // Always written by Python's serializer, so a Dag without either still
     // round-trips to the same object.
diff --git a/ts-sdk/src/index.ts b/ts-sdk/src/index.ts
index 4c629bda76a..865eae75df2 100644
--- a/ts-sdk/src/index.ts
+++ b/ts-sdk/src/index.ts
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-export { Dag } from "./sdk/dag.js";
+export { Dag, label } from "./sdk/dag.js";
 export { triggerDagRun } from "./sdk/trigger-dag-run.js";
 export { Bundle } from "./sdk/bundle.js";
 export { TaskHandler } from "./sdk/task-handler.js";
diff --git a/ts-sdk/src/sdk/dag.ts b/ts-sdk/src/sdk/dag.ts
index d31b5f2fe8d..398d5fc7fb3 100644
--- a/ts-sdk/src/sdk/dag.ts
+++ b/ts-sdk/src/sdk/dag.ts
@@ -270,6 +270,7 @@ export interface TaskGroupOptions {
 export interface OrderEdge {
   readonly upstream: string;
   readonly downstream: string;
+  readonly label?: string;
 }
 
 // A task id cannot hold a NUL, so a joined pair cannot collide with one.
@@ -292,6 +293,15 @@ export interface TaskGroupRecord {
 /** Separates a group ID from what it contains, as Python's `prefix_group_id` 
does. */
 const GROUP_SEPARATOR = ".";
 
+/**
+ * Internal: suffixes of the join nodes the Airflow UI draws at each end of a
+ * task group, as Python's `TaskGroup.upstream_join_id` and
+ * `downstream_join_id` name them.
+ */
+export const UPSTREAM_JOIN_SUFFIX = ".upstream_join_id";
+export const DOWNSTREAM_JOIN_SUFFIX = ".downstream_join_id";
+const JOIN_SUFFIXES = [UPSTREAM_JOIN_SUFFIX, DOWNSTREAM_JOIN_SUFFIX];
+
 /** The Dag's own view of a group, which it appends to as an author declares. 
*/
 interface MutableTaskGroupRecord extends TaskGroupRecord {
   readonly taskIds: string[];
@@ -317,8 +327,52 @@ export function isTaskRef(value: unknown): value is 
TaskRef {
 
 const conditionTasks = new WeakMap<object, TaskRef>();
 
+const labelledNodes = new WeakMap<object, { readonly node: Node; readonly 
text: string }>();
+
 function resolveNode(node: Node): Node {
-  return (typeof node === "object" && node !== null && 
conditionTasks.get(node)) || node;
+  if (typeof node !== "object" || node === null) return node;
+  return labelledNodes.get(node)?.node ?? conditionTasks.get(node) ?? node;
+}
+
+/**
+ * Label the edge that `before` or `after` draws to `node`, as Python's 
`Label` does.
+ * The result only goes into `before` or `after`; calling either on it throws.
+ * `text` cannot be empty.
+ *
+ * ```ts
+ * checked.before(label(processed, "rows found"), label(emptyNotice, "no 
rows"));
+ * ```
+ */
+export function label(node: Node, text: string): Node {
+  if (typeof text !== "string") {
+    throw new Error(`label() takes a string to name the edge with, not 
${describeValue(text)}`);
+  }
+  if (text === "") {
+    throw new Error("label() takes the text to put on the edge, not an empty 
string");
+  }
+  const target = resolveNode(node);
+  const id = nodeId(target);
+  if (id === undefined) {
+    throw new Error(`label() takes a task or task group reference, not 
${describeValue(node)}`);
+  }
+  const misuse = (verb: "before" | "after"): never => {
+    throw new Error(
+      `label(..., ${JSON.stringify(text)}) on "${id}" of Dag "${target.dagId}" 
names the edge ` +
+        `drawn to it, so pass it to before() or after() rather than calling 
${verb}() on it`,
+    );
+  };
+  const labelled: Node = {
+    dagId: target.dagId,
+    before: () => misuse("before"),
+    after: () => misuse("after"),
+  };
+  brand(labelled, "LabelledNode");
+  labelledNodes.set(labelled, { node: target, text });
+  return Object.freeze(labelled);
+}
+
+function isLabelledNode(value: unknown): boolean {
+  return hasBrand(value, "LabelledNode");
 }
 
 /**
@@ -334,6 +388,8 @@ function resolveNode(node: Node): Node {
 function findNestedTaskRef(value: unknown, seen: WeakSet<object>): TaskRef | 
undefined {
   if (typeof value !== "object" || value === null) return undefined;
   if (isTaskRef(value)) return value;
+  const labelled = labelledNodes.get(value)?.node;
+  if (labelled !== undefined) return isTaskRef(labelled) ? labelled : 
undefined;
   // A literal that refers back to itself is not JSON either, but it reaches
   // here before anything else has rejected it.
   if (seen.has(value)) return undefined;
@@ -693,6 +749,7 @@ export class Dag {
             "a condition names each side once",
         );
       }
+      this.#rejectLabel(target, `The "${side}" branch of "${taskId}"`, taskId);
       const taskRef = resolveNode(target) as TaskRef;
       this.#validateOwnNode(taskRef, `the "${side}" branch of "${taskId}"`);
       if (!isTaskRef(taskRef)) {
@@ -800,6 +857,7 @@ export class Dag {
       dagId: this.dagId,
       taskId,
       case: (taskRef) => {
+        this.#rejectLabel(taskRef, `A case of "${taskId}"`, taskId);
         this.#validateOwnNode(taskRef, `a case of "${taskId}"`);
         if (!isTaskRef(taskRef)) {
           throw new Error(
@@ -1070,11 +1128,21 @@ export class Dag {
 
   // Tasks and groups share one namespace, as they do in Python: a serialized
   // Dag addresses both by a bare ID, so `dag.task("x")` and 
`dag.taskGroup("x")`
-  // cannot both exist.
+  // cannot both exist. A group's join node IDs are in it too, since edge 
labels
+  // at a group end are keyed by them.
   #reserveNodeId(id: string, kind: "Task" | "Task group"): void {
     if (this.#tasks.has(id) || this.#groups.has(id)) {
       throw new Error(`${kind} "${id}" is already registered for Dag 
"${this.dagId}"`);
     }
+    for (const suffix of JOIN_SUFFIXES) {
+      const groupId = id.endsWith(suffix) ? id.slice(0, -suffix.length) : 
undefined;
+      if (groupId !== undefined && this.#groups.has(groupId)) {
+        throw new Error(
+          `${kind} "${id}" of Dag "${this.dagId}" takes the ID of a join node 
of task group ` +
+            `"${groupId}"; pick another ID`,
+        );
+      }
+    }
   }
 
   #createTaskGroupRef(groupId: string): TaskGroupRef {
@@ -1121,6 +1189,7 @@ export class Dag {
   }
 
   #addOrderEdge(upstreamNode: Node, downstreamNode: Node, verb: "before" | 
"after"): void {
+    const text = labelledNodes.get(verb === "before" ? downstreamNode : 
upstreamNode)?.text;
     const upstream = resolveNode(upstreamNode);
     const downstream = resolveNode(downstreamNode);
     if (this.#finalized) {
@@ -1143,25 +1212,42 @@ export class Dag {
     }
     const key = `${upstreamId}${EDGE_KEY_SEPARATOR}${downstreamId}`;
     // Idempotent, so an edge drawn from both ends is one edge.
-    if (!this.#orderEdges.has(key)) {
-      this.#orderEdges.set(
-        key,
-        Object.freeze({ upstream: upstreamId!, downstream: downstreamId! }),
+    const edgeLabel = text ?? this.#orderEdges.get(key)?.label;
+    this.#orderEdges.set(
+      key,
+      Object.freeze({
+        upstream: upstreamId!,
+        downstream: downstreamId!,
+        ...(edgeLabel === undefined ? {} : { label: edgeLabel }),
+      }),
+    );
+  }
+
+  #rejectLabel(node: unknown, context: string, deciderId: string): void {
+    if (isLabelledNode(node)) {
+      throw new Error(
+        `${context} of Dag "${this.dagId}" was given a label(); label a branch 
edge by ` +
+          `redrawing it from "${deciderId}" with before(label(task, "text"))`,
       );
     }
   }
 
-  #validateOwnNode(node: Node, label: string): void {
+  #validateOwnNode(node: Node, context: string): void {
     const id = nodeId(node);
+    if (id === undefined && isLabelledNode(node)) {
+      throw new Error(
+        `${context} on Dag "${this.dagId}" was given a label() that 
${DUPLICATE_COPY_HINT}`,
+      );
+    }
     if (id === undefined) {
       throw new Error(
-        `${label} on Dag "${this.dagId}" takes tasks and task groups this Dag 
handed out, ` +
+        `${context} on Dag "${this.dagId}" takes tasks and task groups this 
Dag handed out, ` +
           "not arbitrary values",
       );
     }
     if (node.dagId !== this.dagId) {
       throw new Error(
-        `${label} cannot reach Dag "${node.dagId}" node "${id}" from Dag ` +
+        `${context} cannot reach Dag "${node.dagId}" node "${id}" from Dag ` +
           `"${this.dagId}"; an edge joins two nodes of one Dag`,
       );
     }
@@ -1171,7 +1257,7 @@ export class Dag {
     // the edge at this Dag's own group of that name.
     if (isTaskRef(node) ? this.#tasks.get(id)?.task !== node : 
this.#groupRefs.get(id) !== node) {
       throw new Error(
-        `${label} was given a reference to "${id}" that this Dag did not hand 
out; ` +
+        `${context} was given a reference to "${id}" that this Dag did not 
hand out; ` +
           `it comes from another Dag object with the same ID, or 
${DUPLICATE_COPY_HINT}`,
       );
     }
@@ -1217,6 +1303,12 @@ export class Dag {
         );
       }
       const value = inputs[key];
+      if (isLabelledNode(value)) {
+        throw new Error(
+          `Input "${key}" of task "${taskId}" was given a label(), which only 
goes into ` +
+            "before() or after(); pass the reference itself",
+        );
+      }
       // Anything unbranded is a literal argument, including a look-alike
       // `{dagId, taskId}` object: only a real reference makes an edge.
       if (!isTaskRef(value)) {
diff --git a/ts-sdk/tests/coordinator/serde.test.ts 
b/ts-sdk/tests/coordinator/serde.test.ts
index aa7f533c855..0b15d715750 100644
--- a/ts-sdk/tests/coordinator/serde.test.ts
+++ b/ts-sdk/tests/coordinator/serde.test.ts
@@ -27,6 +27,7 @@ import {
 } from "../../src/coordinator/serde.js";
 import {
   Dag,
+  label,
   type DagSpec,
   type TaskGroupRef,
   type TaskRef,
@@ -485,6 +486,24 @@ describe("serializeDag", () => {
       ]);
     });
 
+    it("writes edge labels keyed by the group join nodes at a group end", () 
=> {
+      const dag = new Dag("d");
+      const extracted = place(dag, "extract");
+      const transformed = place(dag, "transform", [extracted]);
+      const staging = dag.taskGroup("staging");
+      place(staging, "stage");
+      const loaded = place(dag, "load");
+      extracted.before(label(transformed, "rows"));
+      transformed.before(label(staging, "staged"));
+      staging.before(label(loaded, "ready"));
+
+      expect((serializeDag(dag, "", ".") as Json)["edge_info"]).toEqual({
+        extract: { transform: { label: "rows" } },
+        transform: { "staging.upstream_join_id": { label: "staged" } },
+        "staging.downstream_join_id": { load: { label: "ready" } },
+      });
+    });
+
     it("counts one edge when the wiring already drew it", () => {
       const dag = new Dag("d");
       const extracted = place(dag, "extract");
diff --git a/ts-sdk/tests/public-api.test.ts b/ts-sdk/tests/public-api.test.ts
index 1fc2d9bc4fd..ff19a660fc6 100644
--- a/ts-sdk/tests/public-api.test.ts
+++ b/ts-sdk/tests/public-api.test.ts
@@ -49,6 +49,7 @@ import {
   Dag,
   getClient,
   getContext,
+  label,
   NEVER_EXPIRE,
   SUPERVISOR_API_VERSION,
   TaskHandler,
@@ -72,6 +73,7 @@ describe("public API", () => {
       taskId: "public_api_downstream",
     });
     expect(dag.taskIds).toEqual(["public_api_task", "public_api_downstream"]);
+    expect(() => upstream.before(label(downstream, "rows"))).not.toThrow();
     // serve() hands the bundle to the runtime, which needs the supervisor's
     // socket addresses that Airflow puts on argv.
     await expect(new Bundle(dag).serve()).rejects.toThrow("Missing --comm");
diff --git a/ts-sdk/tests/sdk/branching.test.ts 
b/ts-sdk/tests/sdk/branching.test.ts
index fb31c2022ee..1c060ea5c18 100644
--- a/ts-sdk/tests/sdk/branching.test.ts
+++ b/ts-sdk/tests/sdk/branching.test.ts
@@ -24,6 +24,7 @@ import {
   getDagOrderEdges,
   getDagTaskInputs,
   getDagTaskRecords,
+  label,
   type TaskRef,
 } from "../../src/sdk/dag.js";
 import { serializeDag } from "../../src/coordinator/serde.js";
@@ -193,6 +194,15 @@ describe("dag.switch", () => {
   });
 
   describe("rejects", () => {
+    it("a labelled case", () => {
+      const { long, decider } = branchedDag();
+
+      expect(() => decider.case(label(long, "long") as never)).toThrowError(
+        'A case of "pick_path" of Dag "d" was given a label(); label a branch 
edge ' +
+          'by redrawing it from "pick_path" with before(label(task, "text"))',
+      );
+    });
+
     it("a task group as a case", () => {
       const { dag, decider } = branchedDag();
       const group = dag.taskGroup("staging");
diff --git a/ts-sdk/tests/sdk/conditional.test.ts 
b/ts-sdk/tests/sdk/conditional.test.ts
index f2487f2af6a..7cb5e7888aa 100644
--- a/ts-sdk/tests/sdk/conditional.test.ts
+++ b/ts-sdk/tests/sdk/conditional.test.ts
@@ -24,6 +24,7 @@ import {
   getDagOrderEdges,
   getDagTaskInputs,
   getDagTaskRecords,
+  label,
   type TaskRef,
 } from "../../src/sdk/dag.js";
 import { serializeDag } from "../../src/coordinator/serde.js";
@@ -231,7 +232,29 @@ describe("dag.if", () => {
     });
   });
 
+  it("labels a branch edge redrawn from the condition", () => {
+    const { dag, ifReady, gated } = gatedDag();
+    gated.then(ifReady);
+
+    gated.before(label(ifReady, "rows found"));
+
+    expect(getDagOrderEdges(dag)).toContainEqual({
+      upstream: "has_rows",
+      downstream: "load_if_ready",
+      label: "rows found",
+    });
+  });
+
   describe("rejects", () => {
+    it("a labelled branch", () => {
+      const { ifReady, gated } = gatedDag();
+
+      expect(() => gated.then(label(ifReady, "rows found") as 
never)).toThrowError(
+        'The "then" branch of "has_rows" of Dag "d" was given a label(); label 
a branch edge ' +
+          'by redrawing it from "has_rows" with before(label(task, "text"))',
+      );
+    });
+
     it("a task group as a branch", () => {
       const { dag, gated } = gatedDag();
       const group = dag.taskGroup("staging");
diff --git a/ts-sdk/tests/sdk/dag.test.ts b/ts-sdk/tests/sdk/dag.test.ts
index ab9e4b902fb..c0f1be41e3f 100644
--- a/ts-sdk/tests/sdk/dag.test.ts
+++ b/ts-sdk/tests/sdk/dag.test.ts
@@ -25,6 +25,7 @@ import {
   getDagTaskGroups,
   getDagTaskInputs,
   getDagTaskRecords,
+  label,
   type DagSpec,
   type TaskRef,
   type TaskSpec,
@@ -562,6 +563,117 @@ describe("Dag", () => {
       ]);
     });
 
+    it("labels each edge of a fan-out from its own endpoint", () => {
+      const { dag, refs } = placedDag("d", "check", "process", "notify");
+
+      refs.check!.before(label(refs.process!, "rows found"), 
label(refs.notify!, "no rows"));
+      refs.notify!.after(label(refs.process!, "after processing"));
+
+      expect(getDagOrderEdges(dag)).toEqual([
+        { upstream: "check", downstream: "process", label: "rows found" },
+        { upstream: "check", downstream: "notify", label: "no rows" },
+        { upstream: "process", downstream: "notify", label: "after processing" 
},
+      ]);
+    });
+
+    it("replaces a label on a redrawn edge, and keeps it on an unlabelled 
redraw", () => {
+      const { dag, refs } = placedDag("d", "load", "cleanup");
+
+      refs.load!.before(label(refs.cleanup!, "first"));
+      refs.load!.before(label(refs.cleanup!, "second"));
+      refs.cleanup!.after(refs.load!);
+
+      expect(getDagOrderEdges(dag)).toEqual([
+        { upstream: "load", downstream: "cleanup", label: "second" },
+      ]);
+    });
+
+    it("leaves the label out of an unlabelled edge", () => {
+      const { dag, refs } = placedDag("d", "load", "cleanup");
+
+      refs.load!.before(refs.cleanup!);
+
+      expect(getDagOrderEdges(dag)).toStrictEqual([{ upstream: "load", 
downstream: "cleanup" }]);
+    });
+
+    it.each(["before", "after"] as const)("rejects %s() called on a label", 
(verb) => {
+      const { refs } = placedDag("d", "load", "cleanup");
+
+      expect(() => label(refs.load!, 
"rows")[verb](refs.cleanup!)).toThrowError(
+        `label(..., "rows") on "load" of Dag "d" names the edge drawn to it, ` 
+
+          `so pass it to before() or after() rather than calling ${verb}() on 
it`,
+      );
+    });
+
+    it.each([
+      ["undefined", undefined],
+      ["an object", { dagId: "d", taskId: "load" }],
+    ])("rejects a label on %s", (described, node) => {
+      expect(() => label(node as never, "rows")).toThrowError(
+        `label() takes a task or task group reference, not ${described}`,
+      );
+    });
+
+    it("rejects an empty label", () => {
+      const { refs } = placedDag("d", "load");
+
+      expect(() => label(refs.load!, "")).toThrowError(
+        "label() takes the text to put on the edge, not an empty string",
+      );
+    });
+
+    it("rejects a label given as a task input", () => {
+      const dag = new Dag("d");
+      const extracted = dag.task("extract", async () => undefined)();
+      const load = dag.task("load", async (_: { rows: unknown }) => undefined);
+
+      expect(() => load({ rows: label(extracted, "rows") as never 
})).toThrowError(
+        'Input "rows" of task "load" was given a label(), which only goes into 
before() or ' +
+          "after(); pass the reference itself",
+      );
+    });
+
+    it("rejects a label nested in a literal task input", () => {
+      const dag = new Dag("d");
+      const extracted = dag.task("extract", async () => undefined)();
+      const load = dag.task("load", async (_: { rows: unknown }) => undefined);
+
+      expect(() => load({ rows: [label(extracted, "rows")] as never 
})).toThrowError(
+        /Input "rows" of task "load" holds a reference to "extract" inside a 
literal value/,
+      );
+    });
+
+    it("rejects a label given as a decider input", () => {
+      const dag = new Dag("d");
+      const extracted = dag.task("extract", async () => undefined)();
+
+      expect(() =>
+        dag.if(
+          async (_: { rows: unknown }) => true,
+          { rows: label(extracted, "rows") as never },
+          { taskId: "has_rows" },
+        ),
+      ).toThrowError('Input "rows" of task "has_rows" was given a label()');
+    });
+
+    it("rejects a label that is not a string", () => {
+      const { refs } = placedDag("d", "load");
+
+      expect(() => label(refs.load!, 42 as never)).toThrowError(
+        "label() takes a string to name the edge with, not the number 42",
+      );
+    });
+
+    it("rejects a label from another copy of the SDK", () => {
+      const { refs } = placedDag("d", "load", "cleanup");
+      const foreign = { dagId: "d", before: () => foreign, after: () => 
foreign };
+      Object.defineProperty(foreign, 
Symbol.for("airflow.ts-sdk.LabelledNode"), { value: true });
+
+      expect(() => refs.load!.before(foreign)).toThrowError(
+        /before\(\) on Dag "d" was given a label\(\) that comes from a 
different copy/,
+      );
+    });
+
     it.each([
       ["before", (a: TaskRef, b: TaskRef) => a.before(b)],
       ["after", (a: TaskRef, b: TaskRef) => b.after(a)],
@@ -864,6 +976,22 @@ describe("Dag", () => {
       },
     );
 
+    it.each(["upstream_join_id", "downstream_join_id"])(
+      "rejects a task or group taking the %s of its group",
+      (joinId) => {
+        const dag = new Dag("grouped");
+        const staging = dag.taskGroup("staging");
+
+        expect(() => staging.task(joinId, async () => undefined)).toThrowError(
+          `Task "staging.${joinId}" of Dag "grouped" takes the ID of a join 
node of task group ` +
+            '"staging"; pick another ID',
+        );
+        expect(() => staging.taskGroup(joinId)).toThrowError(
+          `Task group "staging.${joinId}" of Dag "grouped" takes the ID of a 
join node`,
+        );
+      },
+    );
+
     it("allows the same group ID under different parents", () => {
       const dag = new Dag("grouped");
       dag

Reply via email to